diff --git a/db/schema.ts b/db/schema.ts index a5886ff..ee96270 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,3 +1,4 @@ +import type { Assumption, EntityGroup, RequestedOperation } from '@developmentfinance/client/wire'; import { sql } from 'drizzle-orm'; import { bigint, @@ -99,15 +100,15 @@ export const shareRef = pgTable( specId: text('spec_id').notNull(), identity: jsonb('identity').notNull(), // Null when no role was asked for as a group. - entityGroups: jsonb('entity_groups'), + entityGroups: jsonb('entity_groups').$type>(), variableText: text('variable_text'), contentVersion: text('content_version'), // Null for a live share and for snapshots stored before the column. artifactDigest: text('artifact_digest'), - operations: jsonb('operations').notNull().default([]), + operations: jsonb('operations').$type().notNull().default([]), // The part's assumptions as the response gave them, [] included, // which the replay echoes. Null for a share stored before the column. - assumptions: jsonb('assumptions'), + assumptions: jsonb('assumptions').$type(), }, (table) => [primaryKey({ columns: [table.shareId, table.partIndex] })], ); diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 1b192b4..2947996 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -16,9 +16,10 @@ RUN npm install -g pnpm@10.5.2 WORKDIR /app -# Manifest + lockfile first, so the dependency layer caches across -# source-only changes. +# Manifest, lockfile and the vendored tarballs they name (`file:vendor/...`) +# first, so the dependency layer caches across source-only changes. COPY package.json pnpm-lock.yaml ./ +COPY vendor ./vendor RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ pnpm install --frozen-lockfile diff --git a/docs/development.md b/docs/development.md index 7a44130..75d3b98 100644 --- a/docs/development.md +++ b/docs/development.md @@ -208,11 +208,20 @@ The `apis`, `ai` and `dcp` repositories run their own dev services. `apis` and ` | `pnpm optimize:svg` | SVGO over `src/assets` (run after adding) | | `pnpm db:generate` | Generate a migration from `db/schema.ts` | | `pnpm db:migrate:local` | Apply migrations to the local database | -| `pnpm graph:types` | Regenerate the graph types from the snapshot | -| `pnpm graph:schema` | Refresh the graph schema snapshot from an apis | -`pnpm graph:schema http://localhost:8083` reads the snapshot from dev apis -through the proxy in [Reach the dev APIs](#reach-the-dev-apis). +## API client package + +The Search, Datasets and Graph types come from `@developmentfinance/client`, vendored as `vendor/developmentfinance-client-0.1.0.tgz`. The tarball is the `typescript/` package of the sdk repository, built and packed. To refresh it, pack that package into `vendor/` and re-lock, so `pnpm-lock.yaml` carries the new tarball's integrity: + +```sh +cd /typescript +pnpm install && pnpm run build +pnpm pack --pack-destination /vendor +cd +pnpm install +``` + +A new package version packs under a new file name. Point the `@developmentfinance/client` entry in `package.json` at it before re-locking. Commit the tarball and the lockfile together. ## Conventions diff --git a/justfile b/justfile index e2ec82f..d038393 100644 --- a/justfile +++ b/justfile @@ -172,21 +172,6 @@ test: build: pnpm build -# src/lib/graph/test/wire.test.ts fails until the committed types match the -# snapshot; the diff of this regeneration is the review artifact. -# -# Rewrite src/lib/graph/schema.d.ts from the Graph API schema snapshot -graph-types: - pnpm graph:types - -# Reads `GET /api/graph/schema` from the given dfo-apis, a local proxy to dev by -# default. The dev service admits only invoker identities, so start the proxy -# first (see "Reach the dev APIs" in docs/development.md). -# -# Refresh the Graph API schema snapshot from a deployed dfo-apis, then the types -graph-schema url="http://localhost:8083": - pnpm graph:schema {{url}} - # Everything ci.yml runs, in CI's order. Green here means green there. check: lint typecheck test build diff --git a/package.json b/package.json index 40b8e83..b359aee 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,7 @@ "lint:css": "stylelint \"src/**/*.scss\"", "lint:css:fix": "stylelint \"src/**/*.scss\" --fix", "typecheck": "tsc --noEmit", - "test": "node --test db/test/*.test.ts src/lib/search/test/*.test.ts src/lib/studio/test/*.test.ts src/lib/chat/test/*.test.ts src/lib/proxy/test/*.test.ts src/lib/auth/test/*.test.ts src/lib/mail/test/*.test.ts src/lib/sources/test/*.test.ts src/lib/test/*.test.ts src/lib/docs/test/*.test.ts src/lib/graph/test/*.test.ts", - "graph:types": "node scripts/graph-wire.mts types", - "graph:schema": "node scripts/graph-wire.mts schema", + "test": "node --test db/test/*.test.ts src/lib/search/test/*.test.ts src/lib/studio/test/*.test.ts src/lib/chat/test/*.test.ts src/lib/proxy/test/*.test.ts src/lib/auth/test/*.test.ts src/lib/mail/test/*.test.ts src/lib/sources/test/*.test.ts src/lib/test/*.test.ts src/lib/docs/test/*.test.ts", "format": "prettier --write \"src/**/*.{ts,tsx,js,json,scss}\"", "optimize:svg": "svgo -f src/assets -r", "db:generate": "drizzle-kit generate", @@ -26,6 +24,7 @@ "dependencies": { "@aws-sdk/client-sesv2": "3.1150.0", "@aws-sdk/credential-providers": "3.1150.0", + "@developmentfinance/client": "file:vendor/developmentfinance-client-0.1.0.tgz", "@fontsource/dm-mono": "^5.3.0", "@fontsource/outfit": "^5.3.0", "@google-cloud/cloud-sql-connector": "^1.12.0", @@ -63,7 +62,6 @@ "esbuild": "^0.25.0", "eslint": "^9", "eslint-config-next": "16.3.0", - "openapi-typescript": "7.13.0", "postcss": "^8.5.26", "postcss-scss": "^4.0.9", "prettier": "3.9.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c673067..b09782e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@aws-sdk/credential-providers': specifier: 3.1150.0 version: 3.1150.0 + '@developmentfinance/client': + specifier: file:vendor/developmentfinance-client-0.1.0.tgz + version: file:vendor/developmentfinance-client-0.1.0.tgz '@fontsource/dm-mono': specifier: ^5.3.0 version: 5.3.0 @@ -120,9 +123,6 @@ importers: eslint-config-next: specifier: 16.3.0 version: 16.3.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) - openapi-typescript: - specifier: 7.13.0 - version: 7.13.0(typescript@5.9.3) postcss: specifier: ^8.5.26 version: 8.5.26 @@ -935,6 +935,16 @@ packages: peerDependencies: postcss-selector-parser: ^7.1.1 + '@developmentfinance/client@file:vendor/developmentfinance-client-0.1.0.tgz': + resolution: {integrity: sha512-shJGkuZFt1Iny5g0E8wKdDdNLmyhbQpaySFVD++PcKeTm8do4igX6rCKHZGKbTgUFSQqtUXGF6TuHjsVZveFQA==, tarball: file:vendor/developmentfinance-client-0.1.0.tgz} + version: 0.1.0 + engines: {node: '>=22.18'} + peerDependencies: + apache-arrow: ^21.0.0 + peerDependenciesMeta: + apache-arrow: + optional: true + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -2234,16 +2244,6 @@ packages: '@radix-ui/rect@1.1.3': resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} - '@redocly/ajv@8.11.2': - resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} - - '@redocly/config@0.22.0': - resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} - - '@redocly/openapi-core@1.34.20': - resolution: {integrity: sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==} - engines: {node: '>=18.17.0', npm: '>=9.5.0'} - '@rollup/plugin-commonjs@28.0.1': resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -3078,10 +3078,6 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3334,9 +3330,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - change-case@5.4.4: - resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -3388,9 +3381,6 @@ packages: colord@2.9.5: resolution: {integrity: sha512-1N4FZkdj0BvP78UatS8VQHP+DnRy/rAyh+eelOONgZNsgatenTe+uApmVrdfknjqvsPdsBhq5tQpnIvClcNWZA==} - colorette@1.4.0: - resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -4410,6 +4400,12 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + hyparquet-writer@0.16.10: + resolution: {integrity: sha512-53fpiA57kdJ2V7X97/VSAzHr8rb8Z2cr126T1pTL9TapZNOP/wNJqFh+jc7dRwwg9TpVMYeRNDMb+r5oiLqwjg==} + + hyparquet@1.31.1: + resolution: {integrity: sha512-tF9580Xb+41+Eb+mH0av1k7m1PWl+PSKrX66Yd7UOZ760cwEXP9/enwHRMSCeVncaubRI8XPb3v580/ujYKK5w==} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -4440,10 +4436,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - index-to-position@1.2.0: - resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} - engines: {node: '>=18'} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4625,10 +4617,6 @@ packages: jose@6.2.12: resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} - js-levenshtein@1.1.6: - resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} - engines: {node: '>=0.10.0'} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4636,10 +4624,6 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true - js-yaml@4.3.2: - resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} - hasBin: true - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4957,10 +4941,6 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@5.1.9: - resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} - engines: {node: '>=10'} - minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -5153,12 +5133,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openapi-typescript@7.13.0: - resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} - hasBin: true - peerDependencies: - typescript: ^5.x - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5196,10 +5170,6 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} - parse-json@8.3.0: - resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} - engines: {node: '>=18'} - path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -5271,10 +5241,6 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -5927,10 +5893,6 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -6016,9 +5978,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uri-js-replace@1.0.1: - resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -6141,9 +6100,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml-ast-parser@0.0.43: - resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} - yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -6372,7 +6328,7 @@ snapshots: '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -6424,7 +6380,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.12 transitivePeerDependencies: @@ -7081,7 +7037,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7206,6 +7162,11 @@ snapshots: dependencies: postcss-selector-parser: 7.1.5 + '@developmentfinance/client@file:vendor/developmentfinance-client-0.1.0.tgz': + dependencies: + hyparquet: 1.31.1 + hyparquet-writer: 0.16.10 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.10.0': @@ -7476,7 +7437,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -7492,7 +7453,7 @@ snapshots: '@eslint/eslintrc@3.3.6': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -8219,29 +8180,6 @@ snapshots: '@radix-ui/rect@1.1.3': {} - '@redocly/ajv@8.11.2': - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js-replace: 1.0.1 - - '@redocly/config@0.22.0': {} - - '@redocly/openapi-core@1.34.20(supports-color@10.2.2)': - dependencies: - '@redocly/ajv': 8.11.2 - '@redocly/config': 0.22.0 - colorette: 1.4.0 - https-proxy-agent: 7.0.6(supports-color@10.2.2) - js-levenshtein: 1.1.6 - js-yaml: 4.3.2 - minimatch: 5.1.9 - pluralize: 8.0.0 - yaml-ast-parser: 0.0.43 - transitivePeerDependencies: - - supports-color - '@rollup/plugin-commonjs@28.0.1(rollup@4.63.4)': dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.63.4) @@ -8880,7 +8818,7 @@ snapshots: '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 eslint: 9.39.5 typescript: 5.9.3 transitivePeerDependencies: @@ -8890,7 +8828,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) '@typescript-eslint/types': 8.67.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8909,7 +8847,7 @@ snapshots: '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) '@typescript-eslint/utils': 8.67.0(eslint@9.39.5)(typescript@5.9.3) - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 eslint: 9.39.5 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -8924,7 +8862,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) '@typescript-eslint/types': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -9114,7 +9052,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -9143,8 +9081,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-colors@4.1.3: {} - ansi-regex@5.0.1: {} ansi-regex@6.3.0: {} @@ -9390,8 +9326,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - change-case@5.4.4: {} - character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -9438,8 +9372,6 @@ snapshots: colord@2.9.5: {} - colorette@1.4.0: {} - comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -9739,11 +9671,9 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3(supports-color@10.2.2): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 10.2.2 decode-named-character-reference@1.3.0: dependencies: @@ -10114,7 +10044,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 eslint: 9.39.5 get-tsconfig: 4.14.3 is-bun-module: 2.0.0 @@ -10246,7 +10176,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -10398,7 +10328,7 @@ snapshots: gaxios@7.1.3: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6(supports-color@10.2.2) + https-proxy-agent: 7.0.6 node-fetch: 3.3.2 rimraf: 5.0.10 transitivePeerDependencies: @@ -10407,7 +10337,7 @@ snapshots: gaxios@7.3.1: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6(supports-color@10.2.2) + https-proxy-agent: 7.0.6 node-fetch: 3.3.2 transitivePeerDependencies: - supports-color @@ -10415,7 +10345,7 @@ snapshots: gaxios@8.0.0: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6(supports-color@10.2.2) + https-proxy-agent: 7.0.6 node-fetch: 3.3.2 transitivePeerDependencies: - supports-color @@ -10679,24 +10609,30 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@10.2.2): + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color + hyparquet-writer@0.16.10: + dependencies: + hyparquet: 1.31.1 + + hyparquet@1.31.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -10722,8 +10658,6 @@ snapshots: imurmurhash@0.1.4: {} - index-to-position@1.2.0: {} - inherits@2.0.4: {} ini@1.3.8: {} @@ -10908,18 +10842,12 @@ snapshots: jose@6.2.12: {} - js-levenshtein@1.1.6: {} - js-tokens@4.0.0: {} js-yaml@4.3.1: dependencies: argparse: 2.0.1 - js-yaml@4.3.2: - dependencies: - argparse: 2.0.1 - jsesc@3.1.0: {} json-bigint@1.0.0: @@ -11412,7 +11340,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -11446,10 +11374,6 @@ snapshots: dependencies: brace-expansion: 1.1.18 - minimatch@5.1.9: - dependencies: - brace-expansion: 2.1.4 - minimatch@9.0.9: dependencies: brace-expansion: 2.1.4 @@ -11594,16 +11518,6 @@ snapshots: dependencies: wrappy: 1.0.2 - openapi-typescript@7.13.0(typescript@5.9.3): - dependencies: - '@redocly/openapi-core': 1.34.20(supports-color@10.2.2) - ansi-colors: 4.1.3 - change-case: 5.4.4 - parse-json: 8.3.0 - supports-color: 10.2.2 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -11655,12 +11569,6 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - parse-json@8.3.0: - dependencies: - '@babel/code-frame': 7.29.7 - index-to-position: 1.2.0 - type-fest: 4.41.0 - path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -11722,8 +11630,6 @@ snapshots: picomatch@4.0.5: {} - pluralize@8.0.0: {} - points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -11983,7 +11889,7 @@ snapshots: require-in-the-middle@8.0.1: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -12421,7 +12327,7 @@ snapshots: cosmiconfig: 9.0.2(typescript@5.9.3) css-functions-list: 3.3.3 css-tree: 3.2.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 file-entry-cache: 11.1.5 @@ -12505,7 +12411,7 @@ snapshots: teeny-request@10.1.4: dependencies: http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6(supports-color@10.2.2) + https-proxy-agent: 7.0.6 node-fetch: 3.3.2 stream-events: 1.0.5 transitivePeerDependencies: @@ -12562,8 +12468,6 @@ snapshots: type-fest@0.7.1: {} - type-fest@4.41.0: {} - typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -12703,8 +12607,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uri-js-replace@1.0.1: {} - uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -12867,8 +12769,6 @@ snapshots: yallist@3.1.1: {} - yaml-ast-parser@0.0.43: {} - yargs-parser@21.1.1: {} yargs@17.7.3: diff --git a/scripts/graph-wire.mts b/scripts/graph-wire.mts deleted file mode 100644 index e442f7e..0000000 --- a/scripts/graph-wire.mts +++ /dev/null @@ -1,116 +0,0 @@ -// Generates src/lib/graph/schema.d.ts from the committed Graph API schema -// snapshot, src/lib/graph/graph-api-schema.json, which is the body of -// dfo-apis' `GET /api/graph/schema`. src/lib/graph/test/wire.test.ts fails -// when the committed types differ from what the snapshot generates. -// -// node scripts/graph-wire.mts types rewrite schema.d.ts from the snapshot -// node scripts/graph-wire.mts schema refresh the snapshot from a deployed -// dfo-apis, then rewrite schema.d.ts -// -// The dev apis admits only invokers, so for dev is a local -// `gcloud run services proxy dfo-dev-apis --region northamerica-northeast1 -// --project dev-fin-observatory --port 8083`, http://localhost:8083 -// (docs/development.md, "Reach the dev APIs"). -import { readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import openapiTS, { astToString } from 'openapi-typescript'; -import { format, resolveConfig } from 'prettier'; - -const GRAPH_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/lib/graph'); - -export const SNAPSHOT = path.join(GRAPH_DIR, 'graph-api-schema.json'); -export const TYPES = path.join(GRAPH_DIR, 'schema.d.ts'); - -const HEADER = `// Generated from src/lib/graph/graph-api-schema.json by \`pnpm graph:types\`. -// Do not edit by hand. - -`; - -type JsonSchema = { $defs?: Record; [member: string]: unknown }; - -/** Each served schema, under its served name, and the definitions the served - * schemas name, as one set of named schemas. A definition two schemas share - * must be the same in both, since the types hold one declaration for it. */ -function components(served: Record): Record { - const named: Record = {}; - - for (const [key, { $defs = {}, ...schema }] of Object.entries(served)) { - for (const [name, definition] of Object.entries($defs)) { - if (name in named && JSON.stringify(named[name]) !== JSON.stringify(definition)) { - throw new Error(`${key} defines ${name} differently from another schema`); - } - - named[name] = definition; - } - - named[key] = schema; - } - - return named; -} - -const prettierFormat = async (text: string, filepath: string) => - format(text, { ...(await resolveConfig(filepath)), filepath }); - -/** The declarations the snapshot generates. The served schemas go in as an - * OpenAPI 3.1 document's components, so each becomes - * `components['schemas'][name]`, and their `$defs` references point at the - * components instead. */ -export async function renderGraphWire(snapshot: string): Promise { - const { schemas } = JSON.parse(snapshot) as { schemas: Record }; - const document = JSON.stringify({ - openapi: '3.1.0', - info: { title: 'dfo-apis Graph API', version: 'snapshot' }, - paths: {}, - components: { schemas: components(schemas) }, - }).replaceAll('"#/$defs/', '"#/components/schemas/'); - const ast = await openapiTS(document, { silent: true }); - - return prettierFormat(HEADER + astToString(ast), TYPES); -} - -// Object keys sorted at every depth, so a refresh diffs by content alone. -const sortKeys = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(sortKeys); - if (!value || typeof value !== 'object') return value; - - return Object.fromEntries( - Object.entries(value) - .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key, member]) => [key, sortKeys(member)]), - ); -}; - -async function writeTypes(): Promise { - await writeFile(TYPES, await renderGraphWire(await readFile(SNAPSHOT, 'utf8'))); - console.log(`wrote ${path.relative(process.cwd(), TYPES)}`); -} - -async function refreshSnapshot(base: string): Promise { - const url = new URL('/api/graph/schema', base); - const res = await fetch(url); - - if (!res.ok) throw new Error(`${url} answered HTTP ${res.status}`); - - // One member per line, so a refresh diffs line by line. - const text = JSON.stringify(sortKeys(await res.json()), null, 4); - - await writeFile(SNAPSHOT, await prettierFormat(text, SNAPSHOT)); - console.log(`wrote ${path.relative(process.cwd(), SNAPSHOT)} from ${url}`); -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - const [command, base] = process.argv.slice(2); - - if (command === 'types') { - await writeTypes(); - } else if (command === 'schema' && base) { - await refreshSnapshot(base); - await writeTypes(); - } else { - console.error('usage: node scripts/graph-wire.mts types | schema '); - process.exit(2); - } -} diff --git a/src/app/s/[id]/download/preparing/PreparingDownload.tsx b/src/app/s/[id]/download/preparing/PreparingDownload.tsx index a105ea7..4ee4c55 100644 --- a/src/app/s/[id]/download/preparing/PreparingDownload.tsx +++ b/src/app/s/[id]/download/preparing/PreparingDownload.tsx @@ -9,7 +9,6 @@ import { pollOperation, type OperationProgress } from '@/lib/search/operationPol import { artifactHref, pinDownloadedVersion } from '@/lib/search/searchClient'; import { renderedFile } from '@/lib/search/shareDownload'; import type { FileRendering } from '@/lib/search/snapshot'; -import type { VersionLinkDocument } from '@/lib/search/versionLink'; type Stage = | { kind: 'preparing'; progress: OperationProgress | null } @@ -45,7 +44,7 @@ const PreparingDownload = ({ const prepare = async () => { try { - const manifest = (await pollOperation( + const manifest = await pollOperation( { operation_id: operationId, status: 'pending', @@ -55,7 +54,7 @@ const PreparingDownload = ({ signal: controller.signal, onProgress: (progress) => setStage({ kind: 'preparing', progress }), }, - )) as VersionLinkDocument | null; + ); const file = renderedFile(manifest, { format, bundle }); if (!manifest || !file) { diff --git a/src/components/organisms/DataSearch/DataSearch.tsx b/src/components/organisms/DataSearch/DataSearch.tsx index f3d5a98..79c7fcb 100644 --- a/src/components/organisms/DataSearch/DataSearch.tsx +++ b/src/components/organisms/DataSearch/DataSearch.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ 'use client'; import classNames from 'classnames'; @@ -70,7 +69,12 @@ import { handoffSeeds, studioHref, } from '@/lib/search/studioHandoff'; -import type { SentenceToken } from '@/lib/search/types'; +import { + hasDefiniteSpec, + isKnownPart, + type ResolveResponse, + type SentenceToken, +} from '@/lib/search/types'; import { parseVersionLink, type VersionLink, versionLinkHref } from '@/lib/search/versionLink'; import { observationsPendingText } from '@/lib/search/view'; import { writeSeeds } from '@/lib/studio/seedStore'; @@ -233,7 +237,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { // remounted — swapped-in DOM nodes the initial observer never saw. const sectionRef = useReveal(12, [phase, promptEpoch]); const [draft, setDraft] = useState(''); - const [suggest, setSuggest] = useState(null); + const [suggest, setSuggest] = useState(null); const [suggestPending, setSuggestPending] = useState(false); const [linkCopied, setLinkCopied] = useState(false); const [shareOpen, setShareOpen] = useState(false); @@ -736,19 +740,17 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { // and would otherwise double its rows — the composed plan already drops // a folded part from the heading/variable lines the same way). const seenSpecIds = new Set(); - const definiteParts = parts - .filter((part) => part.part?.status === 'definite' && part.hydratedSpec) - .filter((part) => { - const specId = part.hydratedSpec!.spec_id; + const definiteParts = parts.filter(hasDefiniteSpec).filter((part) => { + const specId = part.hydratedSpec.spec_id; - if (seenSpecIds.has(specId)) { - return false; - } + if (seenSpecIds.has(specId)) { + return false; + } - seenSpecIds.add(specId); + seenSpecIds.add(specId); - return true; - }); + return true; + }); // True while any definite part's observations are still streaming in // (the base run still loading, or an edit post in flight). The preview // table shows its pending line instead of the omission copy while that @@ -756,8 +758,8 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { const observationsPending = definiteParts.some((part) => part.observationsPending); const liveNote = snapshotLiveNote( definiteParts.map((part) => ({ - specId: part.hydratedSpec!.spec_id, - name: part.variableText ?? part.hydratedSpec?.shape?.label ?? '—', + specId: part.hydratedSpec.spec_id, + name: part.variableText ?? part.hydratedSpec.shape?.label ?? '—', })), unreadableSnapshots, ); @@ -765,13 +767,11 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { // Share: a link for the definite parts on screen (every one, in part // order — a folded part keeps its own position). No numbers leave the // page; the server stores the datasets they resolved to. - const sharedParts = parts.filter( - (part) => part.part?.status === 'definite' && part.hydratedSpec, - ); + const sharedParts = parts.filter(hasDefiniteSpec); const sharedDatasets = sharedParts.map((part) => ({ partIndex: part.index, - specId: part.hydratedSpec!.spec_id, - label: part.variableText ?? part.hydratedSpec?.shape?.label ?? '—', + specId: part.hydratedSpec.spec_id, + label: part.variableText ?? part.hydratedSpec.shape?.label ?? '—', })); const createShare: CreateSearchShare = (mode) => { const sendAction = startAction(); @@ -805,7 +805,13 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { const rawPlan = response?.sentence_plan ?? []; const isSlotted = (index: number) => rawPlan.some((token) => token.kind === 'slot' && tokenPart(token, parts.length) === index); - const candidatesParts = parts.filter((part) => part.part?.status === 'candidates'); + const candidatesParts = parts.flatMap((hydrated) => { + const { part } = hydrated; + + return part && isKnownPart(part) && part.status === 'candidates' + ? [{ ...hydrated, part }] + : []; + }); // Collapses to the first no_data part whose callout is the generic // 'studio' hand-off panel (identical text per part). Flagged/alternate @@ -896,7 +902,11 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { )} {suggest && ( )} @@ -1139,25 +1149,22 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { /> ))} - {candidatesParts.map( - (part) => - part.part && ( - 1 ? part.variableText : null} - onPick={(spec) => { - startAction()({ - action: 'pick_reading', - partIndex: part.index, - specId: spec.spec_id, - }); - pickCandidate(part.index, spec); - }} - onRetry={() => submitQuery(query, 'retry')} - /> - ), - )} + {candidatesParts.map((part) => ( + 1 ? part.variableText : null} + onPick={(spec) => { + startAction()({ + action: 'pick_reading', + partIndex: part.index, + specId: spec.spec_id, + }); + pickCandidate(part.index, spec); + }} + onRetry={() => submitQuery(query, 'retry')} + /> + ))} {definiteParts.length > 0 && ( void; // Re-runs the question, for a reading the client could not read. onRetry: () => void; }) => { - const specs: any[] = useMemo(() => part.candidates?.specs ?? [], [part]); + const specs: any[] = useMemo(() => part.candidates.specs, [part]); const readings = useMemo( () => specs.map((spec) => { diff --git a/src/components/organisms/DataSearch/SearchAnswer.tsx b/src/components/organisms/DataSearch/SearchAnswer.tsx index d931f17..74d240a 100644 --- a/src/components/organisms/DataSearch/SearchAnswer.tsx +++ b/src/components/organisms/DataSearch/SearchAnswer.tsx @@ -221,7 +221,7 @@ const SearchAnswer = ({ // Hands the question to Studio. onStudio: () => void; }) => { - const place = answer.scope.place; + const place = answer.scope.place ?? null; if (answer.kind === 'catalogue') { return ( diff --git a/src/components/organisms/DataSearch/SearchCards.tsx b/src/components/organisms/DataSearch/SearchCards.tsx index 8fec4ef..a7e6627 100644 --- a/src/components/organisms/DataSearch/SearchCards.tsx +++ b/src/components/organisms/DataSearch/SearchCards.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- takes specs as `any` and reads only the members it renders, tolerating any that is missing */ 'use client'; import classNames from 'classnames'; @@ -21,6 +21,7 @@ import { specOrigin, statVarsUnreadable, } from '@/lib/search/view'; +import { knownPart, type ParsedPart, type Part } from '@/lib/search/types'; // A card's meta line, its coverage then its origin (specOrigin beside the // spec's own sentence, shapeOrigin beside any other title). The line clamps @@ -45,21 +46,21 @@ const CardMeta = ({ spec, origin }: { spec: any; origin: string | null }) => { // Takes one part. The caller (DataSearch for a no_data part, or the idle // prompt's suggestion preview reading parts[0]) already knows which. const SearchCards = ({ - part, + part: givenPart, partIndex, onPick, onEditParsed, onStudio, question, }: { - part: any; + part: Part | null; // Omitted for the idle prompt's suggestion preview, which has no part to // edit back into. partIndex?: number; onPick: (text?: string) => void; // Posts an entity_ambiguous pick as a parsed entry, the single-part // plan's escape hatch when there's no slot token to hang a picker off. - onEditParsed?: (partIndex: number, editedPart: any) => void; + onEditParsed?: (partIndex: number, editedPart: ParsedPart) => void; // Hands the question and the datasets on screen to Studio (DataSearch's // openStudio); the no_data Studio callout's button is hidden without it. onStudio?: () => void; @@ -67,6 +68,8 @@ const SearchCards = ({ // unreadable subset's retry. question?: string; }) => { + const part = knownPart(givenPart); + // A definite background suggestion renders as a single pickable card. if (part?.status === 'definite' && part.spec) { const spec = part.spec; diff --git a/src/components/organisms/DataSearch/SearchPanel.tsx b/src/components/organisms/DataSearch/SearchPanel.tsx index 7ca5533..6db68b2 100644 --- a/src/components/organisms/DataSearch/SearchPanel.tsx +++ b/src/components/organisms/DataSearch/SearchPanel.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- takes the spec as `any` and reads only the members the panel renders, tolerating any that is missing */ 'use client'; import classNames from 'classnames'; diff --git a/src/components/organisms/DataSearch/SearchSentence.tsx b/src/components/organisms/DataSearch/SearchSentence.tsx index 865a27f..3ee7109 100644 --- a/src/components/organisms/DataSearch/SearchSentence.tsx +++ b/src/components/organisms/DataSearch/SearchSentence.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- takes specs, slots and options of either picker shape as `any` and reads only the members it renders, tolerating any that is missing */ 'use client'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; @@ -54,23 +54,29 @@ import { entityOptionDcid, filterOptionsByQuery, optionCoverageRankingBlocked, + type OptionLike, shouldShowOptionFilter, } from '@/lib/search/pickerState'; import { spliceSentence } from '@/lib/search/refine'; import { keyRef } from '@/lib/search/searchClient'; import { planCoverageText, planSentenceText, tokenPart } from '@/lib/search/sentencePlan'; -import type { - EntityRoleKey, - GraphRef, - HydratedPart, - IdentityFacetAxis, - IdentityFacets, - NoDataOption, - ParsedPart, - MeasureAxisValue, - RoleAxis, - SentenceToken, - SpecAlternativeMeasure, +import { + type EntityRoleKey, + type GraphRef, + hasDefiniteSpec, + type HydratedPart, + type IdentityFacetAxis, + type IdentityFacetBinding, + isKnownPart, + knownPart, + type MeasureAxisValue, + type NoDataOption, + type ParsedPart, + type PartSpec, + type RoleAxis, + type SentenceToken, + type SlotOption, + type SpecAlternativeMeasure, } from '@/lib/search/types'; import type { WindowApplyValue } from '@/lib/search/windowState'; import { @@ -137,9 +143,10 @@ import type { PlaceOptionsBySpec, RootGroup, SlotOptionsEntry, + SourceSlotOption, } from '@/lib/search/view'; -import type { SharedEdit } from './useSearchRun'; +import type { SharedEdit } from '@/lib/search/sharedEdit'; /** The values each row of a measure or unit menu defines through its info * mark: the current measure's first, then each alternative's, each row @@ -196,8 +203,8 @@ export const answerRefineItems = ({ : []; // An option's info icon, when the option names a graph node. -const optionInfo = (option: any) => { - const dcid = option?.value?.ref?.ref; +const optionInfo = (option: OptionLike) => { + const dcid = option.value?.ref?.ref; return dcid ? : undefined; }; @@ -243,10 +250,14 @@ const MethodChoices = ({ * in this file uses, and a child pick comes back on the next response * already scoped to its own level. */ -const renderLevelOptions = (slot: any, options: any[], onSelect: (binding: any) => void) => { - const optionItem = (option: any, direction?: 'up' | 'down') => ( +const renderLevelOptions = ( + slot: any, + options: readonly SlotOption[], + onSelect: (binding: any) => void, +) => { + const optionItem = (option: SlotOption, direction?: 'up' | 'down') => ( onSelect({ kind: 'value', value: option.value })} > @@ -265,7 +276,7 @@ const renderLevelOptions = (slot: any, options: any[], onSelect: (binding: any) optionItem(root) ) : ( @@ -335,7 +346,7 @@ const FlatOptionList = ({ onSelect, }: { slot: any; - options: any[]; + options: SlotOption[]; onSelect: (binding: any) => void; }) => { const [filter, setFilter] = useState(''); @@ -369,8 +380,8 @@ const FlatOptionList = ({ )} - {shown.map((option: any) => { - const dcid = option?.value?.ref?.ref; + {shown.map((option) => { + const dcid = option.value.ref?.ref; const label = slotOptionLabel(slot, option); return ( @@ -432,7 +443,7 @@ type SearchSentenceProps = { partIndex: number, spec: any, axis: A, - binding: IdentityFacets[A] | null, + binding: IdentityFacetBinding[A] | null, ) => void; // A typed phrase edit or entity_ambiguous pick's edited ParsedPart, // posted as a parsed entry re-resolving just this part. @@ -554,7 +565,7 @@ const EntityOptionRows = ({ rankingBlocked, onApply, }: { - options: any[]; + options: SlotOption[]; role: RoleAxis; currentDcids: string[]; rankingBlocked: boolean; @@ -595,7 +606,7 @@ const EntityOptionRows = ({ /> )} - {current.map((option: any) => { + {current.map((option) => { const dcid = entityOptionDcid(option); return ( @@ -619,8 +630,8 @@ const EntityOptionRows = ({ ); })} - {shown.map((option: any) => { - const dcid = option?.value?.ref?.ref; + {shown.map((option) => { + const dcid = option.value.ref?.ref; return ( ({ ...tokenMenu(index, changes), @@ -786,7 +797,7 @@ export const SearchSentenceHeading = ({ const aboutPanel = ( index: number, token: SentenceToken, - spec: any, + spec: PartSpec, variable?: string | null, ) => { const field = tokenField(token); @@ -808,7 +819,7 @@ export const SearchSentenceHeading = ({ const unitSwitchMenu = ( index: number, token: SentenceToken, - spec: any, + spec: PartSpec, switches: readonly UnitSwitch[], variable?: string | null, ) => { @@ -845,15 +856,13 @@ export const SearchSentenceHeading = ({ ); }; - const definiteParts = parts.filter( - (part) => part.part?.status === 'definite' && part.hydratedSpec, - ); + const definiteParts = parts.filter(hasDefiniteSpec); // A hoisted qualifier's own current value/options come from a // representative definite part — by hoisting's own definition every // contributing part agrees, so any one of them reads the same. const representativePart = definiteParts[0] ?? null; - const representative: any = representativePart?.hydratedSpec ?? null; + const representative: PartSpec | null = representativePart?.hydratedSpec ?? null; // The entity_ambiguous parts with a picker whose ambiguous wordings ride // the plan as their own slot tokens: each carries its menu and its @@ -886,12 +895,15 @@ export const SearchSentenceHeading = ({ token: SentenceToken, index: number, partIndex: number, - spec: any, + spec: PartSpec, restatement: ParsedPart | null, ) => { // Named in each trigger's accessible name when several parts share // the sentence. const variable = parts.length > 1 ? parts[partIndex]?.variableText : null; + // A spec flagged unreadable may carry no slots; its tokens then bind none. + const slots = spec.slots ?? []; + const measureSpec = { ...spec, slots }; // Called only where a menu is drawn, with the refs it can change: // by default the slot the token binds. const panel = (changes: readonly string[] = ownRef(token)) => @@ -969,7 +981,8 @@ export const SearchSentenceHeading = ({ // one. const states = tokenStates(token); const alternatives = token.axis === 'what' ? measureAlternatives(spec) : []; - const groups = token.axis === 'what' ? measureSlotGroups(spec, states, optionsBySpec) : []; + const groups = + token.axis === 'what' ? measureSlotGroups(measureSpec, states, optionsBySpec) : []; if (alternatives.length > 0 || groups.length > 0) { const rowValues = menuRowValues(spec, alternatives); @@ -980,7 +993,9 @@ export const SearchSentenceHeading = ({ return ( 0))} + {...panel( + measureMenuRefs(measureSpec, states, groups, alternatives.length > 0), + )} label={token.text} title="Select Measure" ariaLabel={slotAriaLabel('Measure', token.text, variable)} @@ -1022,15 +1037,15 @@ export const SearchSentenceHeading = ({ if (token.axis === 'source') { const sourceOptions = sourceSlotOptions(spec); - const sourceSlotIndex = (spec.slots ?? []).findIndex( - (slot: any) => slot?.key?.axis === 'source', - ); + const sourceSlotIndex = slots.findIndex((slot) => slot.key.axis === 'source'); if (sourceSlotIndex === -1 || sourceOptions.length === 0) { return aboutPanel(index, token, spec, variable); } - const boundDcid = spec.slots[sourceSlotIndex]?.binding?.value?.ref?.ref; + const sourceBinding = slots[sourceSlotIndex]?.binding; + const boundDcid = + sourceBinding?.kind === 'value' ? sourceBinding.value.ref?.ref : undefined; // A lone source opens the menu too, its row the current pick. // @@ -1040,10 +1055,10 @@ export const SearchSentenceHeading = ({ // attribution rather than a filter: nothing to uncheck. const impliedDcid = boundDcid ?? - sourceOptions.find((option: any) => token.text.trim() === sourceOptionLabel(option)) + sourceOptions.find((option) => token.text.trim() === sourceOptionLabel(option)) ?.value?.ref?.ref; const { current, rest } = splitCurrentPick(sourceOptions, impliedDcid); - const sourceRow = (option: any, checked?: boolean) => { + const sourceRow = (option: SourceSlotOption, checked?: boolean) => { const dcid = option.value.ref.ref; return ( @@ -1063,7 +1078,7 @@ export const SearchSentenceHeading = ({ return ( )} - {rest.map((option: any) => ( + {rest.map((option) => ( keyRef(slot.key) === token.binding_ref, - ); + const slotIndex = slots.findIndex((slot) => keyRef(slot.key) === token.binding_ref); const optionsEntry = token.binding_ref ? slotOptionsEntry(optionsBySpec, spec.spec_id, token.binding_ref) : { options: [], truncated: false }; @@ -1249,7 +1262,7 @@ export const SearchSentenceHeading = ({ token.binding_ref && awaitingSlotOptions(optionsBySpec, spec.spec_id, token.binding_ref, baseStreamOpen) ) { - const field = slotField(spec.slots[slotIndex]); + const field = slotField(slots[slotIndex]); return ( 0 && - !slotOffersChoice(spec.slots[slotIndex], optionsEntry, spec) + !slotOffersChoice(slots[slotIndex], optionsEntry, spec) ) { return aboutPanel(index, token, spec, variable); } @@ -1287,7 +1300,7 @@ export const SearchSentenceHeading = ({ return ( slot?.key?.axis === 'where', - ); + const whereIndex = slots.findIndex((slot) => slot.key.axis === 'where'); if (whereIndex !== -1) { return ( @@ -1431,7 +1442,7 @@ export const SearchSentenceHeading = ({ ); } - const slot = spec.slots[slotIndex]; + const slot = slots[slotIndex]; const onSelect = (binding: any) => onEditSlot(partIndex, spec, slotIndex, binding, token.role ?? 'observationAbout'); // A level-scoped constraint slot (relation populated) skips the @@ -1559,12 +1570,12 @@ export const SearchSentenceHeading = ({ if (token.axis === 'source') { const sourceOptions = sourceSlotOptions(representative); - const sourceSlotIndex = (representative?.slots ?? []).findIndex( - (slot: any) => slot?.key?.axis === 'source', - ); const sourceSlot = - sourceSlotIndex !== -1 ? representative.slots[sourceSlotIndex] : null; - const boundDcid = sourceSlot?.binding?.value?.ref?.ref; + (representative?.slots ?? []).find((slot) => slot.key.axis === 'source') ?? null; + const boundDcid = + sourceSlot?.binding.kind === 'value' + ? sourceSlot.binding.value.ref?.ref + : undefined; // A lone source opens the menu here too — see the per-part // branch above. @@ -1576,7 +1587,7 @@ export const SearchSentenceHeading = ({ title="Select Data Source" ariaLabel={slotAriaLabel('Data source', token.text)} > - {sourceOptions.map((option: any) => { + {sourceOptions.map((option) => { const dcid = option.value.ref.ref; const implicit = !boundDcid && token.text.trim() === sourceOptionLabel(option); @@ -1640,20 +1651,18 @@ export const SearchSentenceHeading = ({ title={entityMenuTitle(entityRole)} ariaLabel={slotAriaLabel(entityRoleLabel(entityRole), label)} > - {entityOptions.map((option: any) => { - const checked = currentEntities.includes(option?.value?.ref?.ref); + {entityOptions.map((option) => { + const dcid = option.value.ref?.ref; + const checked = !!dcid && currentEntities.includes(dcid); return ( - onEditShared({ - axis: 'entity', - role: entityRole, - dcid: option.value.ref.ref, - }) - } + onSelect={() => { + if (dcid) + onEditShared({ axis: 'entity', role: entityRole, dcid }); + }} > {token.text}; } - const wording = ambiguousWordingGroup(token, wirePart.no_data); + const known = knownPart(wirePart); + const wording = ambiguousWordingGroup( + token, + known?.status === 'no_data' ? known.no_data : undefined, + ); // While an edit targeting this part is in flight, its what-token (or // its ambiguous wordings, when the plan carries them) carries an @@ -2000,7 +2013,7 @@ export const SearchSentenceHeading = ({ // A slot's label on part `partIndex`'s spec ("Type of flow"). const slotLabel = (partIndex: number, bindingRef: string): string | null => (parts[partIndex]?.hydratedSpec?.slots ?? []).find( - (slot: any) => keyRef(slot.key) === bindingRef, + (slot) => keyRef(slot.key) === bindingRef, )?.key?.label ?? null; // The action naming the departure's ref when it is a slot ("Change @@ -2069,12 +2082,13 @@ export const SearchSentenceHeading = ({ // the page offers Studio once, since it hands over the whole question. const offered = new Set(); const departureItems: DepartureItem[] = answerDepartures( - answerParts.map((part) => ({ - status: part.part?.status ?? 'unsupported', - variable_text: part.variableText, - departures: part.part?.departures ?? [], - no_data: part.part?.no_data, - restatement: part.part?.restatement, + answerParts.map(({ part, variableText }) => ({ + status: part?.status ?? 'unsupported', + variable_text: variableText, + departures: part?.departures ?? [], + no_data: + part && isKnownPart(part) && part.status === 'no_data' ? part.no_data : undefined, + restatement: part?.restatement, })), rawPlan, ).map(({ key, prefix, departure, action }) => { @@ -2150,7 +2164,11 @@ const RefineEntry = ({ if (item.kind === 'entity') { const { role, entry } = item; - const pick = (option: any) => onEditEntity(part.index, spec, role, [option.value.ref.ref]); + const pick = (option: SlotOption) => { + const dcid = option.value.ref?.ref; + + if (dcid) onEditEntity(part.index, spec, role, [dcid]); + }; const [only] = entry.options; if (entry.options.length === 1 && !entry.truncated) { @@ -2165,9 +2183,9 @@ const RefineEntry = ({ return ( - {entry.options.map((option: any) => ( + {entry.options.map((option) => ( pick(option)} > @@ -2355,9 +2373,7 @@ export const SearchSentenceBody = ({ const [whyOpen, setWhyOpen] = useState(false); const aboutId = useId(); - const definiteParts = parts.filter( - (part) => part.part?.status === 'definite' && part.hydratedSpec, - ); + const definiteParts = parts.filter(hasDefiniteSpec); const editNotes = [ ...new Set( @@ -2380,7 +2396,7 @@ export const SearchSentenceBody = ({ variableText: part.variableText, spec: part.hydratedSpec, warnings: part.part?.warnings, - assumptions: part.part?.assumptions, + assumptions: knownPart(part.part)?.assumptions, })); return ( diff --git a/src/components/organisms/DataSearch/SearchTable.tsx b/src/components/organisms/DataSearch/SearchTable.tsx index 960048d..1ed0295 100644 --- a/src/components/organisms/DataSearch/SearchTable.tsx +++ b/src/components/organisms/DataSearch/SearchTable.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- takes the spec as `any` and reads only the members the table renders, tolerating any that is missing */ 'use client'; import classNames from 'classnames'; diff --git a/src/components/organisms/DataSearch/editors.tsx b/src/components/organisms/DataSearch/editors.tsx index 7327f6c..043270f 100644 --- a/src/components/organisms/DataSearch/editors.tsx +++ b/src/components/organisms/DataSearch/editors.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- takes specs and options of either picker shape as `any` and reads only the members each editor shows, tolerating any that is missing */ 'use client'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; @@ -27,6 +27,7 @@ import { entityOptions } from '@/lib/search/searchClient'; import type { EntityRoleKey, IdentityWindow, PlaceOptionsScope } from '@/lib/search/types'; import { buildPlaceOptionGroups, + observationSeries, optionCoverageLabel, optionMuted, placeEditorScopes, @@ -60,10 +61,10 @@ export const WindowEditor = ({ onApply: (value: WindowApplyValue) => void; }) => { const requestedWindow: IdentityWindow | undefined = spec?.identity?.requested_window; - const years = (spec?.observations?.series ?? []) - .flatMap((series: any) => series?.points ?? []) - .map((point: any) => parseInt(point?.date, 10)) - .filter((year: number) => !Number.isNaN(year)); + const years = observationSeries(spec) + .flatMap((series) => series.points) + .map((point) => parseInt(point.date, 10)) + .filter((year) => !Number.isNaN(year)); const seed = seedWindowYears({ requestedWindow, coverageWindow: spec?.coverage?.window, diff --git a/src/components/organisms/DataSearch/useDatasetDownload.ts b/src/components/organisms/DataSearch/useDatasetDownload.ts index ba117c9..60023cb 100644 --- a/src/components/organisms/DataSearch/useDatasetDownload.ts +++ b/src/components/organisms/DataSearch/useDatasetDownload.ts @@ -293,7 +293,7 @@ export const useDatasetDownload = ({ // The mint answers with this definition's own options and // coverage, so the sheet need not ask the options door for them. - applyOptions(manifest as DatasetOptionsDocument | null); + applyOptions(manifest); optionsAsk.answered(specId); const artifact = artifacts.find((a) => typeof a.url === 'string') ?? null; diff --git a/src/components/organisms/DataSearch/useSearchRun.ts b/src/components/organisms/DataSearch/useSearchRun.ts index 59b865d..5e7d055 100644 --- a/src/components/organisms/DataSearch/useSearchRun.ts +++ b/src/components/organisms/DataSearch/useSearchRun.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- takes the edited spec and slot bindings as `any`, passing members it does not read */ 'use client'; /** @@ -166,7 +166,6 @@ import { measureSwitchEntry, type MeasurePick } from '@/lib/search/measureMenu'; import { buildReplayPlan, nextEditsByPart } from '@/lib/search/permalinkEdits'; import { bindsMethod, - entityOptionsEntry, mergeEntityOptionsEntry, mergePlaceOptionsEntry, mergeSlotOptionsEntry, @@ -186,26 +185,28 @@ import { SearchEvent, SearchInput, } from '@/lib/search/searchClient'; -import type { - BaseIdentity, - CatalogueAnswer, - DatasetIdentityEntry, - DatasetResubmitEntry, - DeclinedAnswer, - EntityRoleKey, - ExpectedPart, - HydratedPart, - IdentityFacetAxis, - IdentityFacets, - IdentityUnitRequest, - ParsedPart, - PartPhase, - Resolution, - ResolveResponse, - RoleAxis, - SentenceToken, - Spec, - SpecObservations, +import { + type BaseIdentity, + type CatalogueAnswer, + type DatasetIdentityEntry, + type DatasetResubmitEntry, + type DeclinedAnswer, + type EntityRoleKey, + type ExpectedPart, + type FacetOptionsEntry, + type HydratedPart, + type IdentityFacetAxis, + type IdentityFacetBinding, + knownPart, + type ParsedPart, + type PartPhase, + type PartSpec, + type Resolution, + type ResolveResponse, + type RoleAxis, + type SentenceToken, + type Spec, + type SpecObservations, } from '@/lib/search/types'; import { searchPostTracker, @@ -217,6 +218,7 @@ import { import { sharedUnitEdit } from '@/lib/search/unitPicker'; import { unmountTeardown } from '@/lib/search/unmountTeardown'; import type { WindowApplyValue } from '@/lib/search/windowState'; +import { type SharedEdit, sharedEditTouched, withSharedEdit } from '@/lib/search/sharedEdit'; export type SearchPhase = 'idle' | 'loading' | 'done' | 'error'; @@ -225,19 +227,6 @@ export type SearchPhase = 'idle' | 'loading' | 'done' | 'error'; export type PartlessAnswer = Pick | Pick; -/** A hoisted where/when/source/origin/unit/method qualifier's edit, fanned - * out to every definite part by `editShared`: one axis, applied everywhere - * it hoisted. A unit request applies to every part that converts - * (`sharedUnitEdit`); a method binding to every part that binds a method, - * the parts a stated method token speaks for. */ -export type SharedEdit = - | { axis: 'where'; binding: any; entityRole: EntityRoleKey } - | { axis: 'source'; binding: any } - | { axis: 'when'; window: WindowApplyValue } - | { axis: 'entity'; role: RoleAxis; dcid: string } - | { axis: 'unit'; request: IdentityUnitRequest } - | { axis: 'method'; binding: IdentityFacets['measurementMethod'] | null }; - type PartRuntime = Pick; const EMPTY_RUNTIME: PartRuntime = { @@ -252,29 +241,24 @@ const addStep = (steps: string[], step: string): string[] => * entries folded in: the same merge every definite part needs, factored * out so N parts hydrate identically. */ function hydrateDefiniteSpec( - spec: Spec, - obsBySpec: Record, - facetsBySpec: Record>, + spec: PartSpec, + obsBySpec: Record, + facetsBySpec: Record>, frozen?: SpecObservations, -): Spec { +): PartSpec { const live = spec.observations ?? obsBySpec[spec.spec_id] ?? null; + // Streamed facet entries override the response's (empty on SSE) by + // axis; a buffered body's inline entries survive untouched. + const streamed = facetsBySpec[spec.spec_id] ?? {}; + const inline = (spec.facet_options ?? []).filter((entry) => !(entry.axis in streamed)); return { ...spec, // A snapshot share's captured bytes win over whatever the live // replay streams for the same spec. observations: frozen ?? live, - // Streamed facet entries override the response's (empty on SSE) by - // axis; a buffered body's inline entries survive untouched. - facet_options: (() => { - const streamed = facetsBySpec[spec.spec_id] ?? {}; - const inline = (spec.facet_options ?? []).filter( - (entry: any) => !(entry?.axis in streamed), - ); - - return [...inline, ...Object.values(streamed)]; - })(), - } as Spec; + facet_options: [...inline, ...Object.values(streamed)], + }; } /** A lightweight `HydratedPart[]` view straight off a fresh `Resolution`, @@ -284,15 +268,19 @@ function hydrateDefiniteSpec( * `replay` uses this to build its entries list from the base run's just- * landed response before React has re-rendered the hook's own `parts`. */ function partsFromResponse(response: Resolution): HydratedPart[] { - return response.parts.map((part, index) => ({ - index, - variableText: part.variable_text ?? null, - part, - hydratedSpec: part.status === 'definite' && part.spec ? part.spec : null, - observationsPending: false, - phase: 'done', - editError: null, - })); + return response.parts.map((part, index) => { + const known = knownPart(part); + + return { + index, + variableText: part.variable_text ?? null, + part, + hydratedSpec: known?.status === 'definite' ? known.spec : null, + observationsPending: false, + phase: 'done', + editError: null, + }; + }); } /** Ends a post's usage tracker on its own `done`: the event describes the @@ -340,13 +328,15 @@ export const useSearchRun = () => { const [expectedParts, setExpectedParts] = useState([]); const [runtimes, setRuntimes] = useState([]); const [editsByPart, setEditsByPart] = useState>({}); - const [obsBySpec, setObsBySpec] = useState>({}); + const [obsBySpec, setObsBySpec] = useState>({}); const [optionsBySpec, setOptionsBySpec] = useState< Record> >({}); // facet_options ride SSE as trailing events (the response's own array is // empty on the stream), collected per spec, axis-keyed. - const [facetsBySpec, setFacetsBySpec] = useState>>({}); + const [facetsBySpec, setFacetsBySpec] = useState< + Record> + >({}); const [entityOptionsBySpec, setEntityOptionsBySpec] = useState({}); // Trailing place_options events, spec_id- and scope-keyed: prefetched // observationAbout peers/children candidates so PlaceEditor can open @@ -514,15 +504,16 @@ export const useSearchRun = () => { return Array.from({ length: count }, (_, index): HydratedPart => { const wirePart = response?.parts[index] ?? null; + const definite = knownPart(wirePart); const runtime = runtimes[index] ?? EMPTY_RUNTIME; const frozenRef = - wirePart?.status === 'definite' && wirePart.spec - ? frozenRefSpecId(snapshotBindings, index, wirePart.spec.spec_id) + definite?.status === 'definite' + ? frozenRefSpecId(snapshotBindings, index, definite.spec.spec_id) : null; const hydratedSpec = - wirePart?.status === 'definite' && wirePart.spec + definite?.status === 'definite' ? hydrateDefiniteSpec( - wirePart.spec, + definite.spec, obsBySpec, facetsBySpec, frozenRef ? frozenObsBySpec[frozenRef] : undefined, @@ -1149,7 +1140,7 @@ export const useSearchRun = () => { partIndex: number, fromSpec: any, axis: A, - binding: IdentityFacets[A] | null, + binding: IdentityFacetBinding[A] | null, ): DatasetEntriesOverride => { const identity = withFacetEdit(fromSpec.identity as BaseIdentity, axis, binding); @@ -1168,7 +1159,7 @@ export const useSearchRun = () => { partIndex: number, fromSpec: any, axis: A, - binding: IdentityFacets[A] | null, + binding: IdentityFacetBinding[A] | null, ) => { runDatasetEdit(facetOverride(partIndex, fromSpec, axis, binding), [partIndex], 'facet'); }, @@ -1396,42 +1387,14 @@ export const useSearchRun = () => { return; } - const touched: number[] = []; - - for (const part of parts) { - if (part.part?.status !== 'definite' || !part.hydratedSpec) continue; - const spec = part.hydratedSpec; - - if (edit.axis === 'entity') { - if (entityOptionsEntry(spec, entityOptionsBySpec, edit.role)) { - touched.push(part.index); - } - continue; - } - if (spec.slots.some((slot) => slot.key.axis === edit.axis)) - touched.push(part.index); - } + const touched = sharedEditTouched(parts, edit, entityOptionsBySpec); if (touched.length === 0) return; - const rewriteIdentity = (identity: BaseIdentity, part: HydratedPart): BaseIdentity => { - if (!touched.includes(part.index) || !part.hydratedSpec) return identity; - const spec = part.hydratedSpec; - - if (edit.axis === 'entity') { - return withEntityEdit(identity, edit.role, [edit.dcid]); - } - if (edit.axis === 'when') { - return { ...identity, requested_window: edit.window }; - } - const slotIndex = spec.slots.findIndex((slot) => slot.key.axis === edit.axis); - return withResolvedSlotEdit( - spec, - slotIndex, - edit.binding, - edit.axis === 'where' ? edit.entityRole : 'observationAbout', - ); - }; + const rewriteIdentity = (identity: BaseIdentity, part: HydratedPart): BaseIdentity => + touched.includes(part.index) && part.hydratedSpec + ? withSharedEdit(identity, part.hydratedSpec, edit) + : identity; runDatasetEdit({ rewriteIdentity }, touched, 'shared'); }, diff --git a/src/lib/graph/graph-api-schema.json b/src/lib/graph/graph-api-schema.json deleted file mode 100644 index a25b6c9..0000000 --- a/src/lib/graph/graph-api-schema.json +++ /dev/null @@ -1,7826 +0,0 @@ -{ - "capabilities": "/api/graph/capabilities", - "schemas": { - "health_response": { - "description": "`GET /healthz`'s own response body, shared across every surface.", - "properties": { - "build": { - "description": "the commit sha the image was built from, the same value the x-dfo-commit response header carries, \"unknown\" on a checkout run with no DFO_BUILD_COMMIT set", - "title": "Build", - "type": "string" - }, - "capability_version": { - "description": "this surface's own capability_version token", - "title": "Capability Version", - "type": "string" - }, - "status": { - "const": "ok", - "description": "always \"ok\" for a 200 response", - "title": "Status", - "type": "string" - } - }, - "required": ["status", "build", "capability_version"], - "title": "HealthResponse", - "type": "object" - }, - "members_response": { - "$defs": { - "AvailabilitySource": { - "description": "A source that holds the variable: its provenance, labelled with the catalogue's name.", - "properties": { - "documentation": { - "description": "the path of the provenance's source document", - "title": "Documentation", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label", "documentation"], - "title": "GraphAvailabilitySource", - "type": "object" - }, - "CodeItem": { - "description": "A code in a `codes` or `children` list, with its code and its parent's dcid.", - "properties": { - "code": { - "title": "Code", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "parent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the parent code's dcid; null for a top-level code", - "title": "Parent" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label", "code", "parent"], - "title": "GraphCodeItem", - "type": "object" - }, - "FacetItem": { - "description": "One facet of a variable, as the `facets` list relation serves it. `ref` is the facet id,\nwhich hashes the facet's metadata.", - "properties": { - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "label": { - "default": null, - "title": "Label", - "type": "null" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "measurement_method": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ] - }, - "observation_period": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Observation Period" - }, - "observations": { - "title": "Observations", - "type": "integer" - }, - "places": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "null for a role-bearing variable", - "title": "Places" - }, - "ref": { - "title": "Ref", - "type": "string" - }, - "scaling": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Scaling" - }, - "source": { - "$ref": "#/$defs/AvailabilitySource" - }, - "unit": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "ref", - "label", - "source", - "unit", - "measurement_method", - "observation_period", - "scaling", - "first_year", - "last_year", - "observations", - "places" - ], - "title": "GraphFacetItem", - "type": "object" - }, - "MemberItem": { - "description": "One item of a list: its dcid and the owner's label for it, `null` where the owner holds\nno name.", - "properties": { - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label"], - "title": "GraphMemberItem", - "type": "object" - }, - "NodeRef": { - "description": "A graph reference. `label` is `null` when the target is not held, carries no `name`, or\ncarries only a `name` equal to its own dcid.", - "properties": { - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label"], - "title": "GraphNodeRef", - "type": "object" - }, - "Projection": { - "description": "A named cut of a membership by member class.", - "enum": [ - "countries", - "other_states", - "territories", - "former_states", - "institutions" - ], - "title": "Projection", - "type": "string" - } - }, - "description": "One page of `GET /api/graph/node/{dcid}/members`.", - "properties": { - "as_of": { - "anyOf": [ - { - "format": "date", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the date membership was read at; null for today", - "title": "As Of" - }, - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the list read", - "propertyNames": { - "enum": ["code_index", "origin_catalogue", "coverage_index", "membership"] - }, - "title": "Built From", - "type": "object" - }, - "complete": { - "description": "whether the walk reached the end of a list the owner holds in full", - "title": "Complete", - "type": "boolean" - }, - "gaps": { - "description": "the code of each way the list is partial", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "items": { - "items": { - "anyOf": [ - { - "$ref": "#/$defs/CodeItem" - }, - { - "$ref": "#/$defs/FacetItem" - }, - { - "$ref": "#/$defs/MemberItem" - } - ] - }, - "title": "Items", - "type": "array" - }, - "next_cursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the cursor for the next page; null when this page ends the list", - "title": "Next Cursor" - }, - "projection": { - "anyOf": [ - { - "$ref": "#/$defs/Projection" - }, - { - "type": "null" - } - ], - "description": "the membership projection listed; null for a relation that takes none" - }, - "ref": { - "description": "the node whose list this is", - "title": "Ref", - "type": "string" - }, - "relation": { - "enum": [ - "codes", - "children", - "datasets", - "variables", - "members", - "countries", - "facets" - ], - "title": "Relation", - "type": "string" - }, - "total": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "the list's length; null where the list is partial", - "title": "Total" - } - }, - "required": [ - "ref", - "relation", - "projection", - "as_of", - "items", - "next_cursor", - "complete", - "total", - "gaps", - "built_from" - ], - "title": "GraphMembersPage", - "type": "object" - }, - "node_response": { - "$defs": { - "AggregateNode": { - "properties": { - "aggregate": { - "$ref": "#/$defs/PartyBlock" - }, - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "aggregate", - "default": "aggregate", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "aggregate" - ], - "title": "AggregateNode", - "type": "object" - }, - "Authority": { - "description": "Where a membership's rows come from, with the data version that reproduces them.", - "properties": { - "name": { - "title": "Name", - "type": "string" - }, - "version": { - "title": "Version", - "type": "string" - } - }, - "required": ["name", "version"], - "title": "GraphMembershipAuthority", - "type": "object" - }, - "Availability": { - "description": "Where DFO holds observations of a variable. `sources: []` means it verifiably holds\nnone.", - "properties": { - "default": { - "anyOf": [ - { - "$ref": "#/$defs/AvailabilityDefault" - }, - { - "type": "null" - } - ], - "description": "null when no facet dispatches a request naming nothing" - }, - "more_sources": { - "description": "the sources past the first ten", - "title": "More Sources", - "type": "integer" - }, - "scope": { - "const": "dfo", - "default": "dfo", - "title": "Scope", - "type": "string" - }, - "sources": { - "items": { - "$ref": "#/$defs/SourceAvailability" - }, - "title": "Sources", - "type": "array" - }, - "summary": { - "$ref": "#/$defs/AvailabilitySummary" - } - }, - "required": ["scope", "summary", "default", "sources", "more_sources"], - "title": "GraphAvailability", - "type": "object" - }, - "AvailabilityDefault": { - "description": "What a data request naming no place, years or facet receives.", - "properties": { - "facets": { - "description": "the facet ids the request is served, in order", - "items": { - "type": "string" - }, - "title": "Facets", - "type": "array" - }, - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "measurement_method": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ] - }, - "observation_period": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Observation Period" - }, - "places": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "null for a role-bearing variable", - "title": "Places" - }, - "source": { - "anyOf": [ - { - "$ref": "#/$defs/AvailabilitySource" - }, - { - "type": "null" - } - ] - }, - "unit": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ] - }, - "without_data": { - "description": "the places, or parties, holding the variable that the default leaves without data", - "title": "Without Data", - "type": "integer" - } - }, - "required": [ - "facets", - "source", - "unit", - "measurement_method", - "observation_period", - "first_year", - "last_year", - "places", - "without_data" - ], - "title": "GraphAvailabilityDefault", - "type": "object" - }, - "AvailabilitySource": { - "description": "A source that holds the variable: its provenance, labelled with the catalogue's name.", - "properties": { - "documentation": { - "description": "the path of the provenance's source document", - "title": "Documentation", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label", "documentation"], - "title": "GraphAvailabilitySource", - "type": "object" - }, - "AvailabilitySummary": { - "description": "The variable across every source.", - "properties": { - "entities": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/$defs/EntityCounts" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "description": "the entities covered per graph role; null for any other variable", - "title": "Entities" - }, - "facets": { - "title": "Facets", - "type": "integer" - }, - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "places": { - "anyOf": [ - { - "additionalProperties": { - "type": "integer" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "description": "the places covered by kind; null for a role-bearing variable, and while the places' kinds are unread (a transient gap)", - "title": "Places" - }, - "sources": { - "title": "Sources", - "type": "integer" - } - }, - "required": [ - "places", - "entities", - "first_year", - "last_year", - "sources", - "facets" - ], - "title": "GraphAvailabilitySummary", - "type": "object" - }, - "Binding": { - "description": "The `scope.facets` values that request a series from Datasets, in its leading unit.", - "properties": { - "measurementMethod": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Measurementmethod" - }, - "provenance": { - "title": "Provenance", - "type": "string" - }, - "unit": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Unit" - } - }, - "required": ["provenance", "measurementMethod", "unit"], - "title": "GraphAvailabilityBinding", - "type": "object" - }, - "CodeBlock": { - "description": "A code's detail block.", - "properties": { - "code": { - "title": "Code", - "type": "string" - }, - "code_system": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the system's graph node, such as `CRSPurposeCodeEnum`" - }, - "parent": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the code it specialises; null for a root" - }, - "property": { - "description": "the constraint property the code is a value of", - "title": "Property", - "type": "string" - }, - "synonyms": { - "items": { - "type": "string" - }, - "title": "Synonyms", - "type": "array" - } - }, - "required": ["code_system", "property", "code", "parent", "synonyms"], - "title": "GraphCodeBlock", - "type": "object" - }, - "CodeNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "code": { - "$ref": "#/$defs/CodeBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "code", - "default": "code", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "code" - ], - "title": "CodeNode", - "type": "object" - }, - "CodeSystemBlock": { - "description": "A code system's detail block.", - "properties": { - "code_count": { - "description": "how many codes the code index holds for the system", - "title": "Code Count", - "type": "integer" - }, - "declarations": { - "$ref": "#/$defs/CodeSystemDeclarations" - }, - "plain_name": { - "description": "the property's plain name, such as `sector`", - "title": "Plain Name", - "type": "string" - }, - "property": { - "description": "the constraint property the system governs", - "title": "Property", - "type": "string" - } - }, - "required": ["property", "plain_name", "declarations", "code_count"], - "title": "GraphCodeSystemBlock", - "type": "object" - }, - "CodeSystemDeclarations": { - "description": "The rules a code system declares (`code_index.types.CodeSystemDeclaration`).", - "properties": { - "absent_reading": { - "description": "how a variable that does not declare the property reads", - "enum": ["not_declared", "aggregate"], - "title": "Absent Reading", - "type": "string" - }, - "disjoint_from_children": { - "description": "whether a parent code's records sit apart from its children's, so a parent's total is its own records plus its descendants' (CRS purposes); false where a parent row already totals its children (DAC1 aid types)", - "title": "Disjoint From Children", - "type": "boolean" - }, - "hierarchical": { - "description": "whether its codes nest through specializationOf", - "title": "Hierarchical", - "type": "boolean" - }, - "summable": { - "description": "whether sibling values can rebuild a parent total", - "title": "Summable", - "type": "boolean" - } - }, - "required": [ - "hierarchical", - "disjoint_from_children", - "summable", - "absent_reading" - ], - "title": "GraphCodeSystemDeclarations", - "type": "object" - }, - "CodeSystemNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "code_system": { - "$ref": "#/$defs/CodeSystemBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "code_system", - "default": "code_system", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "code_system" - ], - "title": "CodeSystemNode", - "type": "object" - }, - "Constraint": { - "description": "One constraint: the property and the value the variable fixes it at.", - "properties": { - "property": { - "$ref": "#/$defs/NodeRef" - }, - "value": { - "$ref": "#/$defs/NodeRef" - } - }, - "required": ["property", "value"], - "title": "GraphDefinitionConstraint", - "type": "object" - }, - "ContinentNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "continent": { - "$ref": "#/$defs/PlaceBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "continent", - "default": "continent", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "continent" - ], - "title": "ContinentNode", - "type": "object" - }, - "ContinentalUnionNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "continental_union": { - "$ref": "#/$defs/PlaceBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "continental_union", - "default": "continental_union", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "continental_union" - ], - "title": "ContinentalUnionNode", - "type": "object" - }, - "CountryNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "country": { - "$ref": "#/$defs/PlaceBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "country", - "default": "country", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "country" - ], - "title": "CountryNode", - "type": "object" - }, - "DataCommonsRef": { - "description": "The Data Commons that serves a fact. `id` is the wire enum; `label` its display name.", - "properties": { - "id": { - "enum": ["dfo", "base", "un_system"], - "title": "Id", - "type": "string" - }, - "label": { - "title": "Label", - "type": "string" - } - }, - "required": ["id", "label"], - "title": "GraphDataCommonsRef", - "type": "object" - }, - "DatasetNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "dataset": { - "$ref": "#/$defs/SourceBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "dataset", - "default": "dataset", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "dataset" - ], - "title": "DatasetNode", - "type": "object" - }, - "DefinitionText": { - "description": "The graph's prose for one part of a definition.", - "properties": { - "of": { - "$ref": "#/$defs/NodeRef", - "description": "the part the text defines" - }, - "text": { - "title": "Text", - "type": "string" - } - }, - "required": ["of", "text"], - "title": "GraphDefinitionText", - "type": "object" - }, - "EntityCounts": { - "description": "The distinct entities in one role, split by their kind in the code index.", - "properties": { - "parties": { - "description": "entities with figures of their own", - "title": "Parties", - "type": "integer" - }, - "residual": { - "description": "entities holding figures no other entity does", - "title": "Residual", - "type": "integer" - }, - "totals": { - "description": "entities that hold other entities' figures", - "title": "Totals", - "type": "integer" - }, - "unclassified": { - "description": "entities the code index does not list", - "title": "Unclassified", - "type": "integer" - } - }, - "required": ["parties", "totals", "residual", "unclassified"], - "title": "GraphAvailabilityEntityCounts", - "type": "object" - }, - "Freshness": { - "description": "A source's own refresh cadence, read from DCP provenance arcs that imports populate\nunevenly: `dc/base/WHO_COVID19` carries both `sourceReleaseFrequency` and\n`automaticRefreshType`, `dc/base/BIS_CentralBankPolicyRate` only the second, and\n`dc/base/WHO_GHO` neither. Nullability is the contract.\n\n`release_frequency` is the raw `sourceReleaseFrequency` ISO 8601 duration (`P1M`);\n`release_frequency_label` is `materialise.cadence.cadence_label`'s display label for it\n(`Monthly`), or the raw token when that module does not recognise it.\n\n`refresh_type` is `automaticRefreshType`'s value: how this deployment refreshes the\nsource, such as `GCPCloudRunAutomaticRefresh`, or the sentinel `NoAutomaticRefresh`.", - "properties": { - "refresh_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Refresh Type" - }, - "release_frequency": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Release Frequency" - }, - "release_frequency_label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Release Frequency Label" - } - }, - "required": ["release_frequency", "release_frequency_label", "refresh_type"], - "title": "Freshness", - "type": "object" - }, - "GroupNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "group": { - "$ref": "#/$defs/MembershipBlock" - }, - "kind": { - "const": "group", - "default": "group", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "group" - ], - "title": "GroupNode", - "type": "object" - }, - "Measure": { - "description": "What the variable counts or sums, as the code index names it.", - "properties": { - "label": { - "description": "the measure's noun phrase, such as `net disbursements`", - "title": "Label", - "type": "string" - } - }, - "required": ["label"], - "title": "GraphDefinitionMeasure", - "type": "object" - }, - "MembershipBlock": { - "description": "A group's or region's membership under its declared authority.", - "properties": { - "as_of": { - "anyOf": [ - { - "format": "date", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the date the read asked for; null for today", - "title": "As Of" - }, - "authority": { - "$ref": "#/$defs/Authority" - }, - "effective_date": { - "anyOf": [ - { - "format": "date", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the latest membership change on or before the date; null for an undated membership", - "title": "Effective Date" - }, - "projections": { - "items": { - "$ref": "#/$defs/ProjectionCount" - }, - "title": "Projections", - "type": "array" - } - }, - "required": ["authority", "as_of", "effective_date", "projections"], - "title": "GraphMembershipBlock", - "type": "object" - }, - "NationalCurrency": { - "description": "The units that are each covered place's own currency.", - "properties": { - "currencies": { - "title": "Currencies", - "type": "integer" - }, - "places": { - "title": "Places", - "type": "integer" - } - }, - "required": ["currencies", "places"], - "title": "GraphAvailabilityNationalCurrency", - "type": "object" - }, - "NodeRef": { - "description": "A graph reference. `label` is `null` when the target is not held, carries no `name`, or\ncarries only a `name` equal to its own dcid.", - "properties": { - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label"], - "title": "GraphNodeRef", - "type": "object" - }, - "OrganisationNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "organisation", - "default": "organisation", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "organisation": { - "$ref": "#/$defs/PartyBlock" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "organisation" - ], - "title": "OrganisationNode", - "type": "object" - }, - "OtherNode": { - "description": "A node no other kind fits. `types` and `description` carry what the graph holds.", - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "other", - "default": "other", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind" - ], - "title": "OtherNode", - "type": "object" - }, - "PartyBlock": { - "description": "An organisation's or aggregate's detail block.", - "properties": { - "roles": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "description": "the role properties a node of these types can fill, by the role ranges the graph declares (rangeIncludes and its subClassOf closure); null when that read failed", - "title": "Roles" - } - }, - "required": ["roles"], - "title": "GraphPartyBlock", - "type": "object" - }, - "PlaceBlock": { - "description": "A place's detail block. `membership` is set for a region the membership policy holds.", - "properties": { - "contained_in": { - "description": "the containedInPlace targets", - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Contained In", - "type": "array" - }, - "membership": { - "anyOf": [ - { - "$ref": "#/$defs/MembershipBlock" - }, - { - "type": "null" - } - ] - } - }, - "required": ["contained_in", "membership"], - "title": "GraphPlaceBlock", - "type": "object" - }, - "PlaceOtherNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "place_other", - "default": "place_other", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "place_other": { - "$ref": "#/$defs/PlaceBlock" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "place_other" - ], - "title": "PlaceOtherNode", - "type": "object" - }, - "PriceBasisWords": { - "description": "Current or constant prices, with the base year of constant prices when known.", - "properties": { - "base_year": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Base Year" - }, - "prices": { - "enum": ["current", "constant"], - "title": "Prices", - "type": "string" - } - }, - "required": ["prices", "base_year"], - "title": "GraphPriceBasis", - "type": "object" - }, - "Projection": { - "description": "A named cut of a membership by member class.", - "enum": [ - "countries", - "other_states", - "territories", - "former_states", - "institutions" - ], - "title": "Projection", - "type": "string" - }, - "ProjectionCount": { - "description": "One projection of a membership and its member count at the block's date.", - "properties": { - "complete": { - "description": "false when the authority does not record the projection, or is known to hold it only in part (`membership_incomplete`)", - "title": "Complete", - "type": "boolean" - }, - "count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "members under the projection; null when the authority does not record it", - "title": "Count" - }, - "projection": { - "$ref": "#/$defs/Projection" - } - }, - "required": ["projection", "count", "complete"], - "title": "GraphProjectionCount", - "type": "object" - }, - "ProvenanceNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "provenance", - "default": "provenance", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "provenance": { - "$ref": "#/$defs/SourceBlock" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "provenance" - ], - "title": "ProvenanceNode", - "type": "object" - }, - "Series": { - "description": "A source's facets sharing a measurement method, period and scaling.", - "properties": { - "binding": { - "$ref": "#/$defs/Binding" - }, - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "measurement_method": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "null when no facet states a method: no stated method" - }, - "observation_period": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Observation Period" - }, - "observations": { - "title": "Observations", - "type": "integer" - }, - "places": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "null for a role-bearing variable", - "title": "Places" - }, - "scaling": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Scaling" - }, - "units": { - "$ref": "#/$defs/UnitsFold" - } - }, - "required": [ - "measurement_method", - "observation_period", - "scaling", - "first_year", - "last_year", - "places", - "observations", - "units", - "binding" - ], - "title": "GraphAvailabilitySeries", - "type": "object" - }, - "SourceAvailability": { - "description": "One source's facets of the variable, and their series.", - "properties": { - "entities": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/$defs/EntityCounts" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "description": "the entities covered per graph role; null for any other variable", - "title": "Entities" - }, - "facets": { - "title": "Facets", - "type": "integer" - }, - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "places": { - "anyOf": [ - { - "additionalProperties": { - "type": "integer" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "description": "the places covered by kind; null for a role-bearing variable, and while the places' kinds are unread (a transient gap)", - "title": "Places" - }, - "series": { - "items": { - "$ref": "#/$defs/Series" - }, - "title": "Series", - "type": "array" - }, - "source": { - "$ref": "#/$defs/AvailabilitySource" - } - }, - "required": [ - "source", - "places", - "entities", - "first_year", - "last_year", - "facets", - "series" - ], - "title": "GraphAvailabilitySourceEntry", - "type": "object" - }, - "SourceBlock": { - "description": "A Source, Dataset or Provenance's detail block. `documentation` is the path of the\nnode's source document on this API.", - "properties": { - "data_commons": { - "anyOf": [ - { - "$ref": "#/$defs/DataCommonsRef" - }, - { - "type": "null" - } - ], - "description": "the Data Commons serving this provenance, from the origin catalogue; null for a provenance the catalogue does not admit, a Dataset, or a Source" - }, - "description_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the descriptionUrl arc's value", - "title": "Description Url" - }, - "documentation": { - "description": "the path of the node's source document", - "title": "Documentation", - "type": "string" - }, - "earliest_observation_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the earliestObservationDate arc's value", - "title": "Earliest Observation Date" - }, - "freshness": { - "$ref": "#/$defs/Freshness" - }, - "last_data_refresh_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the lastDataRefreshDate arc's value", - "title": "Last Data Refresh Date" - }, - "latest_observation_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the latestObservationDate arc's value", - "title": "Latest Observation Date" - }, - "license": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "License" - }, - "license_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "License Type" - }, - "next_data_refresh_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the nextDataRefreshDate arc's value", - "title": "Next Data Refresh Date" - }, - "parent": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the isPartOf arc's target; null when the node has no parent Dataset" - }, - "publisher": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the source arc's target; null when the node carries no source arc" - }, - "source_data_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the sourceDataUrl arc's value", - "title": "Source Data Url" - }, - "transformation_logic_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the dataTransformationLogic arc's value: a URL to the transformation code", - "title": "Transformation Logic Url" - }, - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Url" - } - }, - "required": [ - "publisher", - "parent", - "license", - "license_type", - "url", - "source_data_url", - "description_url", - "transformation_logic_url", - "last_data_refresh_date", - "next_data_refresh_date", - "earliest_observation_date", - "latest_observation_date", - "freshness", - "data_commons", - "documentation" - ], - "title": "GraphSourceBlock", - "type": "object" - }, - "SourceNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "source", - "default": "source", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "source": { - "$ref": "#/$defs/SourceBlock" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "source" - ], - "title": "SourceNode", - "type": "object" - }, - "StatVarBlock": { - "description": "A variable's detail block: what it measures and where DFO holds observations of it.", - "properties": { - "availability": { - "anyOf": [ - { - "$ref": "#/$defs/Availability" - }, - { - "type": "null" - } - ], - "description": "where DFO holds observations of the variable; null, and named in gaps, when its read failed or the coverage index is not loaded" - }, - "definition": { - "$ref": "#/$defs/VariableDefinition" - } - }, - "required": ["definition", "availability"], - "title": "GraphStatVarBlock", - "type": "object" - }, - "StatVarNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "stat_var", - "default": "stat_var", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "stat_var": { - "$ref": "#/$defs/StatVarBlock" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "stat_var" - ], - "title": "StatVarNode", - "type": "object" - }, - "SubregionNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "subregion", - "default": "subregion", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "subregion": { - "$ref": "#/$defs/PlaceBlock" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "subregion" - ], - "title": "SubregionNode", - "type": "object" - }, - "UnitBlock": { - "description": "A unit's detail block: the words `unit_words` names it by.", - "properties": { - "changes_magnitude": { - "description": "whether the unit changes what a number means, so a heading states it", - "title": "Changes Magnitude", - "type": "boolean" - }, - "currency": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "its ISO 4217 code when it is a currency", - "title": "Currency" - }, - "currency_noun": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the plural noun a currency reads as, such as `US dollars`", - "title": "Currency Noun" - }, - "price_basis": { - "anyOf": [ - { - "$ref": "#/$defs/PriceBasisWords" - }, - { - "type": "null" - } - ], - "description": "the price basis the unit itself states; null when it states none" - }, - "unit_class": { - "$ref": "#/$defs/UnitKind", - "description": "currency, purchasing_power, percent, or other (`unit_words.unit_class`)" - } - }, - "required": [ - "unit_class", - "currency", - "currency_noun", - "price_basis", - "changes_magnitude" - ], - "title": "GraphUnitBlock", - "type": "object" - }, - "UnitCount": { - "description": "One unit of a series. `ref` is `null` for facets that state no unit.", - "properties": { - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "places": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "the places its facets cover; null for a role-bearing variable", - "title": "Places" - }, - "ref": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Ref" - } - }, - "required": ["ref", "label", "places"], - "title": "GraphAvailabilityUnit", - "type": "object" - }, - "UnitKind": { - "enum": ["currency", "purchasing_power", "percent", "other", "none"], - "type": "string" - }, - "UnitNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "unit", - "default": "unit", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - }, - "unit": { - "$ref": "#/$defs/UnitBlock" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "unit" - ], - "title": "UnitNode", - "type": "object" - }, - "UnitsFold": { - "description": "A series' units: how many, the two covering the most places, and how many more.", - "properties": { - "count": { - "title": "Count", - "type": "integer" - }, - "leading": { - "items": { - "$ref": "#/$defs/UnitCount" - }, - "title": "Leading", - "type": "array" - }, - "more": { - "title": "More", - "type": "integer" - }, - "national_currency": { - "anyOf": [ - { - "$ref": "#/$defs/NationalCurrency" - }, - { - "type": "null" - } - ], - "description": "the units that are their places' national currency; null when none is" - } - }, - "required": ["count", "leading", "more", "national_currency"], - "title": "GraphAvailabilityUnits", - "type": "object" - }, - "VariableDefinition": { - "description": "What a variable measures, part by part.\n\n`constraints` are ordered by property dcid: the code index holds a variable's constraints\nas a mapping with no declared order, and the graph's `constraintProperties` arc has none.", - "properties": { - "constraints": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/Constraint" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "description": "each constraint the measure does not state, ordered by property dcid; null when the read of a variable's constraint values failed", - "title": "Constraints" - }, - "definitions": { - "description": "the graph's prose per part: the variable's own, then the measure's, then each constraint value's, in constraint order; a part with none is left out", - "items": { - "$ref": "#/$defs/DefinitionText" - }, - "title": "Definitions", - "type": "array" - }, - "measure": { - "anyOf": [ - { - "$ref": "#/$defs/Measure" - }, - { - "type": "null" - } - ], - "description": "the measure the code index names; null when it names none" - }, - "measured_property": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the measuredProperty; null when the variable declares none" - }, - "population_type": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the populationType; null when the variable declares none" - }, - "roles": { - "description": "the entity roles the variable's observations carry, from its declared observationProperties, defaulting to observationAbout when it declares none", - "items": { - "type": "string" - }, - "title": "Roles", - "type": "array" - }, - "source_indicator_codes": { - "description": "the sourceIndicatorCode arc's values: the codes naming the source series, as the publisher codes it (IMF WEO GDP carries NGDP and NGDPD); empty when it has none", - "items": { - "type": "string" - }, - "title": "Source Indicator Codes", - "type": "array" - }, - "stat_type": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the statType; null when the variable declares none or its read failed" - } - }, - "required": [ - "population_type", - "measured_property", - "stat_type", - "measure", - "roles", - "source_indicator_codes", - "constraints", - "definitions" - ], - "title": "GraphVariableDefinition", - "type": "object" - } - }, - "discriminator": { - "mapping": { - "aggregate": "#/$defs/AggregateNode", - "code": "#/$defs/CodeNode", - "code_system": "#/$defs/CodeSystemNode", - "continent": "#/$defs/ContinentNode", - "continental_union": "#/$defs/ContinentalUnionNode", - "country": "#/$defs/CountryNode", - "dataset": "#/$defs/DatasetNode", - "group": "#/$defs/GroupNode", - "organisation": "#/$defs/OrganisationNode", - "other": "#/$defs/OtherNode", - "place_other": "#/$defs/PlaceOtherNode", - "provenance": "#/$defs/ProvenanceNode", - "source": "#/$defs/SourceNode", - "stat_var": "#/$defs/StatVarNode", - "subregion": "#/$defs/SubregionNode", - "unit": "#/$defs/UnitNode" - }, - "propertyName": "kind" - }, - "oneOf": [ - { - "$ref": "#/$defs/StatVarNode" - }, - { - "$ref": "#/$defs/CodeSystemNode" - }, - { - "$ref": "#/$defs/CodeNode" - }, - { - "$ref": "#/$defs/GroupNode" - }, - { - "$ref": "#/$defs/CountryNode" - }, - { - "$ref": "#/$defs/ContinentNode" - }, - { - "$ref": "#/$defs/SubregionNode" - }, - { - "$ref": "#/$defs/ContinentalUnionNode" - }, - { - "$ref": "#/$defs/PlaceOtherNode" - }, - { - "$ref": "#/$defs/OrganisationNode" - }, - { - "$ref": "#/$defs/AggregateNode" - }, - { - "$ref": "#/$defs/UnitNode" - }, - { - "$ref": "#/$defs/SourceNode" - }, - { - "$ref": "#/$defs/DatasetNode" - }, - { - "$ref": "#/$defs/ProvenanceNode" - }, - { - "$ref": "#/$defs/OtherNode" - } - ] - }, - "nodes_request": { - "additionalProperties": false, - "description": "`dcids`: 1 to `NODES_MAX_DCIDS` node dcids read as one batch, in one DCP-read\nexecutor job. Each dcid matches the GET route's `{dcid}` path syntax and its 512-character\nceiling.", - "properties": { - "dcids": { - "items": { - "maxLength": 512, - "pattern": "^[A-Za-z0-9_.\\-]+(?:/[A-Za-z0-9_.\\-]+)*$", - "type": "string" - }, - "maxItems": 50, - "minItems": 1, - "title": "Dcids", - "type": "array" - } - }, - "required": ["dcids"], - "title": "GraphNodesRequest", - "type": "object" - }, - "nodes_response": { - "$defs": { - "AggregateNode": { - "properties": { - "aggregate": { - "$ref": "#/$defs/PartyBlock" - }, - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "aggregate", - "default": "aggregate", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "aggregate" - ], - "title": "AggregateNode", - "type": "object" - }, - "Authority": { - "description": "Where a membership's rows come from, with the data version that reproduces them.", - "properties": { - "name": { - "title": "Name", - "type": "string" - }, - "version": { - "title": "Version", - "type": "string" - } - }, - "required": ["name", "version"], - "title": "GraphMembershipAuthority", - "type": "object" - }, - "Availability": { - "description": "Where DFO holds observations of a variable. `sources: []` means it verifiably holds\nnone.", - "properties": { - "default": { - "anyOf": [ - { - "$ref": "#/$defs/AvailabilityDefault" - }, - { - "type": "null" - } - ], - "description": "null when no facet dispatches a request naming nothing" - }, - "more_sources": { - "description": "the sources past the first ten", - "title": "More Sources", - "type": "integer" - }, - "scope": { - "const": "dfo", - "default": "dfo", - "title": "Scope", - "type": "string" - }, - "sources": { - "items": { - "$ref": "#/$defs/SourceAvailability" - }, - "title": "Sources", - "type": "array" - }, - "summary": { - "$ref": "#/$defs/AvailabilitySummary" - } - }, - "required": ["scope", "summary", "default", "sources", "more_sources"], - "title": "GraphAvailability", - "type": "object" - }, - "AvailabilityDefault": { - "description": "What a data request naming no place, years or facet receives.", - "properties": { - "facets": { - "description": "the facet ids the request is served, in order", - "items": { - "type": "string" - }, - "title": "Facets", - "type": "array" - }, - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "measurement_method": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ] - }, - "observation_period": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Observation Period" - }, - "places": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "null for a role-bearing variable", - "title": "Places" - }, - "source": { - "anyOf": [ - { - "$ref": "#/$defs/AvailabilitySource" - }, - { - "type": "null" - } - ] - }, - "unit": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ] - }, - "without_data": { - "description": "the places, or parties, holding the variable that the default leaves without data", - "title": "Without Data", - "type": "integer" - } - }, - "required": [ - "facets", - "source", - "unit", - "measurement_method", - "observation_period", - "first_year", - "last_year", - "places", - "without_data" - ], - "title": "GraphAvailabilityDefault", - "type": "object" - }, - "AvailabilitySource": { - "description": "A source that holds the variable: its provenance, labelled with the catalogue's name.", - "properties": { - "documentation": { - "description": "the path of the provenance's source document", - "title": "Documentation", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label", "documentation"], - "title": "GraphAvailabilitySource", - "type": "object" - }, - "AvailabilitySummary": { - "description": "The variable across every source.", - "properties": { - "entities": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/$defs/EntityCounts" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "description": "the entities covered per graph role; null for any other variable", - "title": "Entities" - }, - "facets": { - "title": "Facets", - "type": "integer" - }, - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "places": { - "anyOf": [ - { - "additionalProperties": { - "type": "integer" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "description": "the places covered by kind; null for a role-bearing variable, and while the places' kinds are unread (a transient gap)", - "title": "Places" - }, - "sources": { - "title": "Sources", - "type": "integer" - } - }, - "required": [ - "places", - "entities", - "first_year", - "last_year", - "sources", - "facets" - ], - "title": "GraphAvailabilitySummary", - "type": "object" - }, - "Binding": { - "description": "The `scope.facets` values that request a series from Datasets, in its leading unit.", - "properties": { - "measurementMethod": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Measurementmethod" - }, - "provenance": { - "title": "Provenance", - "type": "string" - }, - "unit": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Unit" - } - }, - "required": ["provenance", "measurementMethod", "unit"], - "title": "GraphAvailabilityBinding", - "type": "object" - }, - "CodeBlock": { - "description": "A code's detail block.", - "properties": { - "code": { - "title": "Code", - "type": "string" - }, - "code_system": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the system's graph node, such as `CRSPurposeCodeEnum`" - }, - "parent": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the code it specialises; null for a root" - }, - "property": { - "description": "the constraint property the code is a value of", - "title": "Property", - "type": "string" - }, - "synonyms": { - "items": { - "type": "string" - }, - "title": "Synonyms", - "type": "array" - } - }, - "required": ["code_system", "property", "code", "parent", "synonyms"], - "title": "GraphCodeBlock", - "type": "object" - }, - "CodeNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "code": { - "$ref": "#/$defs/CodeBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "code", - "default": "code", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "code" - ], - "title": "CodeNode", - "type": "object" - }, - "CodeSystemBlock": { - "description": "A code system's detail block.", - "properties": { - "code_count": { - "description": "how many codes the code index holds for the system", - "title": "Code Count", - "type": "integer" - }, - "declarations": { - "$ref": "#/$defs/CodeSystemDeclarations" - }, - "plain_name": { - "description": "the property's plain name, such as `sector`", - "title": "Plain Name", - "type": "string" - }, - "property": { - "description": "the constraint property the system governs", - "title": "Property", - "type": "string" - } - }, - "required": ["property", "plain_name", "declarations", "code_count"], - "title": "GraphCodeSystemBlock", - "type": "object" - }, - "CodeSystemDeclarations": { - "description": "The rules a code system declares (`code_index.types.CodeSystemDeclaration`).", - "properties": { - "absent_reading": { - "description": "how a variable that does not declare the property reads", - "enum": ["not_declared", "aggregate"], - "title": "Absent Reading", - "type": "string" - }, - "disjoint_from_children": { - "description": "whether a parent code's records sit apart from its children's, so a parent's total is its own records plus its descendants' (CRS purposes); false where a parent row already totals its children (DAC1 aid types)", - "title": "Disjoint From Children", - "type": "boolean" - }, - "hierarchical": { - "description": "whether its codes nest through specializationOf", - "title": "Hierarchical", - "type": "boolean" - }, - "summable": { - "description": "whether sibling values can rebuild a parent total", - "title": "Summable", - "type": "boolean" - } - }, - "required": [ - "hierarchical", - "disjoint_from_children", - "summable", - "absent_reading" - ], - "title": "GraphCodeSystemDeclarations", - "type": "object" - }, - "CodeSystemNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "code_system": { - "$ref": "#/$defs/CodeSystemBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "code_system", - "default": "code_system", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "code_system" - ], - "title": "CodeSystemNode", - "type": "object" - }, - "Constraint": { - "description": "One constraint: the property and the value the variable fixes it at.", - "properties": { - "property": { - "$ref": "#/$defs/NodeRef" - }, - "value": { - "$ref": "#/$defs/NodeRef" - } - }, - "required": ["property", "value"], - "title": "GraphDefinitionConstraint", - "type": "object" - }, - "ContinentNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "continent": { - "$ref": "#/$defs/PlaceBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "continent", - "default": "continent", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "continent" - ], - "title": "ContinentNode", - "type": "object" - }, - "ContinentalUnionNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "continental_union": { - "$ref": "#/$defs/PlaceBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "continental_union", - "default": "continental_union", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "continental_union" - ], - "title": "ContinentalUnionNode", - "type": "object" - }, - "CountryNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "country": { - "$ref": "#/$defs/PlaceBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "country", - "default": "country", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "country" - ], - "title": "CountryNode", - "type": "object" - }, - "DataCommonsRef": { - "description": "The Data Commons that serves a fact. `id` is the wire enum; `label` its display name.", - "properties": { - "id": { - "enum": ["dfo", "base", "un_system"], - "title": "Id", - "type": "string" - }, - "label": { - "title": "Label", - "type": "string" - } - }, - "required": ["id", "label"], - "title": "GraphDataCommonsRef", - "type": "object" - }, - "DatasetNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "dataset": { - "$ref": "#/$defs/SourceBlock" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "dataset", - "default": "dataset", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "dataset" - ], - "title": "DatasetNode", - "type": "object" - }, - "DefinitionText": { - "description": "The graph's prose for one part of a definition.", - "properties": { - "of": { - "$ref": "#/$defs/NodeRef", - "description": "the part the text defines" - }, - "text": { - "title": "Text", - "type": "string" - } - }, - "required": ["of", "text"], - "title": "GraphDefinitionText", - "type": "object" - }, - "EntityCounts": { - "description": "The distinct entities in one role, split by their kind in the code index.", - "properties": { - "parties": { - "description": "entities with figures of their own", - "title": "Parties", - "type": "integer" - }, - "residual": { - "description": "entities holding figures no other entity does", - "title": "Residual", - "type": "integer" - }, - "totals": { - "description": "entities that hold other entities' figures", - "title": "Totals", - "type": "integer" - }, - "unclassified": { - "description": "entities the code index does not list", - "title": "Unclassified", - "type": "integer" - } - }, - "required": ["parties", "totals", "residual", "unclassified"], - "title": "GraphAvailabilityEntityCounts", - "type": "object" - }, - "Freshness": { - "description": "A source's own refresh cadence, read from DCP provenance arcs that imports populate\nunevenly: `dc/base/WHO_COVID19` carries both `sourceReleaseFrequency` and\n`automaticRefreshType`, `dc/base/BIS_CentralBankPolicyRate` only the second, and\n`dc/base/WHO_GHO` neither. Nullability is the contract.\n\n`release_frequency` is the raw `sourceReleaseFrequency` ISO 8601 duration (`P1M`);\n`release_frequency_label` is `materialise.cadence.cadence_label`'s display label for it\n(`Monthly`), or the raw token when that module does not recognise it.\n\n`refresh_type` is `automaticRefreshType`'s value: how this deployment refreshes the\nsource, such as `GCPCloudRunAutomaticRefresh`, or the sentinel `NoAutomaticRefresh`.", - "properties": { - "refresh_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Refresh Type" - }, - "release_frequency": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Release Frequency" - }, - "release_frequency_label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Release Frequency Label" - } - }, - "required": ["release_frequency", "release_frequency_label", "refresh_type"], - "title": "Freshness", - "type": "object" - }, - "GroupNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "group": { - "$ref": "#/$defs/MembershipBlock" - }, - "kind": { - "const": "group", - "default": "group", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "group" - ], - "title": "GroupNode", - "type": "object" - }, - "Measure": { - "description": "What the variable counts or sums, as the code index names it.", - "properties": { - "label": { - "description": "the measure's noun phrase, such as `net disbursements`", - "title": "Label", - "type": "string" - } - }, - "required": ["label"], - "title": "GraphDefinitionMeasure", - "type": "object" - }, - "MembershipBlock": { - "description": "A group's or region's membership under its declared authority.", - "properties": { - "as_of": { - "anyOf": [ - { - "format": "date", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the date the read asked for; null for today", - "title": "As Of" - }, - "authority": { - "$ref": "#/$defs/Authority" - }, - "effective_date": { - "anyOf": [ - { - "format": "date", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the latest membership change on or before the date; null for an undated membership", - "title": "Effective Date" - }, - "projections": { - "items": { - "$ref": "#/$defs/ProjectionCount" - }, - "title": "Projections", - "type": "array" - } - }, - "required": ["authority", "as_of", "effective_date", "projections"], - "title": "GraphMembershipBlock", - "type": "object" - }, - "NationalCurrency": { - "description": "The units that are each covered place's own currency.", - "properties": { - "currencies": { - "title": "Currencies", - "type": "integer" - }, - "places": { - "title": "Places", - "type": "integer" - } - }, - "required": ["currencies", "places"], - "title": "GraphAvailabilityNationalCurrency", - "type": "object" - }, - "NodeRef": { - "description": "A graph reference. `label` is `null` when the target is not held, carries no `name`, or\ncarries only a `name` equal to its own dcid.", - "properties": { - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label"], - "title": "GraphNodeRef", - "type": "object" - }, - "NotHeldEntry": { - "description": "A batch entry for a dcid where `GET /api/graph/node/{dcid}` answers 404\n`node_not_held`.", - "properties": { - "ref": { - "title": "Ref", - "type": "string" - }, - "status": { - "const": "not_held", - "default": "not_held", - "title": "Status", - "type": "string" - } - }, - "required": ["ref", "status"], - "title": "GraphNotHeldEntry", - "type": "object" - }, - "OrganisationNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "organisation", - "default": "organisation", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "organisation": { - "$ref": "#/$defs/PartyBlock" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "organisation" - ], - "title": "OrganisationNode", - "type": "object" - }, - "OtherNode": { - "description": "A node no other kind fits. `types` and `description` carry what the graph holds.", - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "other", - "default": "other", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind" - ], - "title": "OtherNode", - "type": "object" - }, - "PartyBlock": { - "description": "An organisation's or aggregate's detail block.", - "properties": { - "roles": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "description": "the role properties a node of these types can fill, by the role ranges the graph declares (rangeIncludes and its subClassOf closure); null when that read failed", - "title": "Roles" - } - }, - "required": ["roles"], - "title": "GraphPartyBlock", - "type": "object" - }, - "PlaceBlock": { - "description": "A place's detail block. `membership` is set for a region the membership policy holds.", - "properties": { - "contained_in": { - "description": "the containedInPlace targets", - "items": { - "$ref": "#/$defs/NodeRef" - }, - "title": "Contained In", - "type": "array" - }, - "membership": { - "anyOf": [ - { - "$ref": "#/$defs/MembershipBlock" - }, - { - "type": "null" - } - ] - } - }, - "required": ["contained_in", "membership"], - "title": "GraphPlaceBlock", - "type": "object" - }, - "PlaceOtherNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "place_other", - "default": "place_other", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "place_other": { - "$ref": "#/$defs/PlaceBlock" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "place_other" - ], - "title": "PlaceOtherNode", - "type": "object" - }, - "PriceBasisWords": { - "description": "Current or constant prices, with the base year of constant prices when known.", - "properties": { - "base_year": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Base Year" - }, - "prices": { - "enum": ["current", "constant"], - "title": "Prices", - "type": "string" - } - }, - "required": ["prices", "base_year"], - "title": "GraphPriceBasis", - "type": "object" - }, - "Projection": { - "description": "A named cut of a membership by member class.", - "enum": [ - "countries", - "other_states", - "territories", - "former_states", - "institutions" - ], - "title": "Projection", - "type": "string" - }, - "ProjectionCount": { - "description": "One projection of a membership and its member count at the block's date.", - "properties": { - "complete": { - "description": "false when the authority does not record the projection, or is known to hold it only in part (`membership_incomplete`)", - "title": "Complete", - "type": "boolean" - }, - "count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "members under the projection; null when the authority does not record it", - "title": "Count" - }, - "projection": { - "$ref": "#/$defs/Projection" - } - }, - "required": ["projection", "count", "complete"], - "title": "GraphProjectionCount", - "type": "object" - }, - "ProvenanceNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "provenance", - "default": "provenance", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "provenance": { - "$ref": "#/$defs/SourceBlock" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "provenance" - ], - "title": "ProvenanceNode", - "type": "object" - }, - "Series": { - "description": "A source's facets sharing a measurement method, period and scaling.", - "properties": { - "binding": { - "$ref": "#/$defs/Binding" - }, - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "measurement_method": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "null when no facet states a method: no stated method" - }, - "observation_period": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Observation Period" - }, - "observations": { - "title": "Observations", - "type": "integer" - }, - "places": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "null for a role-bearing variable", - "title": "Places" - }, - "scaling": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Scaling" - }, - "units": { - "$ref": "#/$defs/UnitsFold" - } - }, - "required": [ - "measurement_method", - "observation_period", - "scaling", - "first_year", - "last_year", - "places", - "observations", - "units", - "binding" - ], - "title": "GraphAvailabilitySeries", - "type": "object" - }, - "SourceAvailability": { - "description": "One source's facets of the variable, and their series.", - "properties": { - "entities": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/$defs/EntityCounts" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "description": "the entities covered per graph role; null for any other variable", - "title": "Entities" - }, - "facets": { - "title": "Facets", - "type": "integer" - }, - "first_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "First Year" - }, - "last_year": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Year" - }, - "places": { - "anyOf": [ - { - "additionalProperties": { - "type": "integer" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "description": "the places covered by kind; null for a role-bearing variable, and while the places' kinds are unread (a transient gap)", - "title": "Places" - }, - "series": { - "items": { - "$ref": "#/$defs/Series" - }, - "title": "Series", - "type": "array" - }, - "source": { - "$ref": "#/$defs/AvailabilitySource" - } - }, - "required": [ - "source", - "places", - "entities", - "first_year", - "last_year", - "facets", - "series" - ], - "title": "GraphAvailabilitySourceEntry", - "type": "object" - }, - "SourceBlock": { - "description": "A Source, Dataset or Provenance's detail block. `documentation` is the path of the\nnode's source document on this API.", - "properties": { - "data_commons": { - "anyOf": [ - { - "$ref": "#/$defs/DataCommonsRef" - }, - { - "type": "null" - } - ], - "description": "the Data Commons serving this provenance, from the origin catalogue; null for a provenance the catalogue does not admit, a Dataset, or a Source" - }, - "description_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the descriptionUrl arc's value", - "title": "Description Url" - }, - "documentation": { - "description": "the path of the node's source document", - "title": "Documentation", - "type": "string" - }, - "earliest_observation_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the earliestObservationDate arc's value", - "title": "Earliest Observation Date" - }, - "freshness": { - "$ref": "#/$defs/Freshness" - }, - "last_data_refresh_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the lastDataRefreshDate arc's value", - "title": "Last Data Refresh Date" - }, - "latest_observation_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the latestObservationDate arc's value", - "title": "Latest Observation Date" - }, - "license": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "License" - }, - "license_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "License Type" - }, - "next_data_refresh_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the nextDataRefreshDate arc's value", - "title": "Next Data Refresh Date" - }, - "parent": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the isPartOf arc's target; null when the node has no parent Dataset" - }, - "publisher": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the source arc's target; null when the node carries no source arc" - }, - "source_data_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the sourceDataUrl arc's value", - "title": "Source Data Url" - }, - "transformation_logic_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the dataTransformationLogic arc's value: a URL to the transformation code", - "title": "Transformation Logic Url" - }, - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Url" - } - }, - "required": [ - "publisher", - "parent", - "license", - "license_type", - "url", - "source_data_url", - "description_url", - "transformation_logic_url", - "last_data_refresh_date", - "next_data_refresh_date", - "earliest_observation_date", - "latest_observation_date", - "freshness", - "data_commons", - "documentation" - ], - "title": "GraphSourceBlock", - "type": "object" - }, - "SourceNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "source", - "default": "source", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "source": { - "$ref": "#/$defs/SourceBlock" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "source" - ], - "title": "SourceNode", - "type": "object" - }, - "StatVarBlock": { - "description": "A variable's detail block: what it measures and where DFO holds observations of it.", - "properties": { - "availability": { - "anyOf": [ - { - "$ref": "#/$defs/Availability" - }, - { - "type": "null" - } - ], - "description": "where DFO holds observations of the variable; null, and named in gaps, when its read failed or the coverage index is not loaded" - }, - "definition": { - "$ref": "#/$defs/VariableDefinition" - } - }, - "required": ["definition", "availability"], - "title": "GraphStatVarBlock", - "type": "object" - }, - "StatVarNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "stat_var", - "default": "stat_var", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "stat_var": { - "$ref": "#/$defs/StatVarBlock" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "stat_var" - ], - "title": "StatVarNode", - "type": "object" - }, - "SubregionNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "subregion", - "default": "subregion", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "subregion": { - "$ref": "#/$defs/PlaceBlock" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "subregion" - ], - "title": "SubregionNode", - "type": "object" - }, - "UnitBlock": { - "description": "A unit's detail block: the words `unit_words` names it by.", - "properties": { - "changes_magnitude": { - "description": "whether the unit changes what a number means, so a heading states it", - "title": "Changes Magnitude", - "type": "boolean" - }, - "currency": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "its ISO 4217 code when it is a currency", - "title": "Currency" - }, - "currency_noun": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the plural noun a currency reads as, such as `US dollars`", - "title": "Currency Noun" - }, - "price_basis": { - "anyOf": [ - { - "$ref": "#/$defs/PriceBasisWords" - }, - { - "type": "null" - } - ], - "description": "the price basis the unit itself states; null when it states none" - }, - "unit_class": { - "$ref": "#/$defs/UnitKind", - "description": "currency, purchasing_power, percent, or other (`unit_words.unit_class`)" - } - }, - "required": [ - "unit_class", - "currency", - "currency_noun", - "price_basis", - "changes_magnitude" - ], - "title": "GraphUnitBlock", - "type": "object" - }, - "UnitCount": { - "description": "One unit of a series. `ref` is `null` for facets that state no unit.", - "properties": { - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "places": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "the places its facets cover; null for a role-bearing variable", - "title": "Places" - }, - "ref": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Ref" - } - }, - "required": ["ref", "label", "places"], - "title": "GraphAvailabilityUnit", - "type": "object" - }, - "UnitKind": { - "enum": ["currency", "purchasing_power", "percent", "other", "none"], - "type": "string" - }, - "UnitNode": { - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the response read", - "propertyNames": { - "enum": [ - "code_index", - "origin_catalogue", - "coverage_index", - "membership" - ] - }, - "title": "Built From", - "type": "object" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's prose in the graph; null when it has none", - "title": "Description" - }, - "gaps": { - "description": "the member path of every value this door cannot know", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "kind": { - "const": "unit", - "default": "unit", - "title": "Kind", - "type": "string" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none", - "title": "Label" - }, - "links": { - "additionalProperties": { - "type": "string" - }, - "description": "each list relation the node has, mapped to its route; empty when none", - "propertyNames": { - "enum": [ - "codes", - "children", - "members", - "countries", - "datasets", - "variables", - "facets" - ] - }, - "title": "Links", - "type": "object" - }, - "ref": { - "description": "the node's dcid", - "title": "Ref", - "type": "string" - }, - "types": { - "description": "the node's typeOf values, as the graph holds them", - "items": { - "type": "string" - }, - "title": "Types", - "type": "array" - }, - "unit": { - "$ref": "#/$defs/UnitBlock" - } - }, - "required": [ - "ref", - "label", - "types", - "description", - "links", - "gaps", - "built_from", - "kind", - "unit" - ], - "title": "UnitNode", - "type": "object" - }, - "UnitsFold": { - "description": "A series' units: how many, the two covering the most places, and how many more.", - "properties": { - "count": { - "title": "Count", - "type": "integer" - }, - "leading": { - "items": { - "$ref": "#/$defs/UnitCount" - }, - "title": "Leading", - "type": "array" - }, - "more": { - "title": "More", - "type": "integer" - }, - "national_currency": { - "anyOf": [ - { - "$ref": "#/$defs/NationalCurrency" - }, - { - "type": "null" - } - ], - "description": "the units that are their places' national currency; null when none is" - } - }, - "required": ["count", "leading", "more", "national_currency"], - "title": "GraphAvailabilityUnits", - "type": "object" - }, - "VariableDefinition": { - "description": "What a variable measures, part by part.\n\n`constraints` are ordered by property dcid: the code index holds a variable's constraints\nas a mapping with no declared order, and the graph's `constraintProperties` arc has none.", - "properties": { - "constraints": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/Constraint" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "description": "each constraint the measure does not state, ordered by property dcid; null when the read of a variable's constraint values failed", - "title": "Constraints" - }, - "definitions": { - "description": "the graph's prose per part: the variable's own, then the measure's, then each constraint value's, in constraint order; a part with none is left out", - "items": { - "$ref": "#/$defs/DefinitionText" - }, - "title": "Definitions", - "type": "array" - }, - "measure": { - "anyOf": [ - { - "$ref": "#/$defs/Measure" - }, - { - "type": "null" - } - ], - "description": "the measure the code index names; null when it names none" - }, - "measured_property": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the measuredProperty; null when the variable declares none" - }, - "population_type": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the populationType; null when the variable declares none" - }, - "roles": { - "description": "the entity roles the variable's observations carry, from its declared observationProperties, defaulting to observationAbout when it declares none", - "items": { - "type": "string" - }, - "title": "Roles", - "type": "array" - }, - "source_indicator_codes": { - "description": "the sourceIndicatorCode arc's values: the codes naming the source series, as the publisher codes it (IMF WEO GDP carries NGDP and NGDPD); empty when it has none", - "items": { - "type": "string" - }, - "title": "Source Indicator Codes", - "type": "array" - }, - "stat_type": { - "anyOf": [ - { - "$ref": "#/$defs/NodeRef" - }, - { - "type": "null" - } - ], - "description": "the statType; null when the variable declares none or its read failed" - } - }, - "required": [ - "population_type", - "measured_property", - "stat_type", - "measure", - "roles", - "source_indicator_codes", - "constraints", - "definitions" - ], - "title": "GraphVariableDefinition", - "type": "object" - } - }, - "description": "`nodes` is index-aligned with the request's `dcids`: a repeated dcid repeats its\nentry. Each entry is the body `GET /api/graph/node/{dcid}` returns for that dcid, or a\n`not_held` entry where that route answers 404.", - "properties": { - "nodes": { - "items": { - "anyOf": [ - { - "discriminator": { - "mapping": { - "aggregate": "#/$defs/AggregateNode", - "code": "#/$defs/CodeNode", - "code_system": "#/$defs/CodeSystemNode", - "continent": "#/$defs/ContinentNode", - "continental_union": "#/$defs/ContinentalUnionNode", - "country": "#/$defs/CountryNode", - "dataset": "#/$defs/DatasetNode", - "group": "#/$defs/GroupNode", - "organisation": "#/$defs/OrganisationNode", - "other": "#/$defs/OtherNode", - "place_other": "#/$defs/PlaceOtherNode", - "provenance": "#/$defs/ProvenanceNode", - "source": "#/$defs/SourceNode", - "stat_var": "#/$defs/StatVarNode", - "subregion": "#/$defs/SubregionNode", - "unit": "#/$defs/UnitNode" - }, - "propertyName": "kind" - }, - "oneOf": [ - { - "$ref": "#/$defs/StatVarNode" - }, - { - "$ref": "#/$defs/CodeSystemNode" - }, - { - "$ref": "#/$defs/CodeNode" - }, - { - "$ref": "#/$defs/GroupNode" - }, - { - "$ref": "#/$defs/CountryNode" - }, - { - "$ref": "#/$defs/ContinentNode" - }, - { - "$ref": "#/$defs/SubregionNode" - }, - { - "$ref": "#/$defs/ContinentalUnionNode" - }, - { - "$ref": "#/$defs/PlaceOtherNode" - }, - { - "$ref": "#/$defs/OrganisationNode" - }, - { - "$ref": "#/$defs/AggregateNode" - }, - { - "$ref": "#/$defs/UnitNode" - }, - { - "$ref": "#/$defs/SourceNode" - }, - { - "$ref": "#/$defs/DatasetNode" - }, - { - "$ref": "#/$defs/ProvenanceNode" - }, - { - "$ref": "#/$defs/OtherNode" - } - ] - }, - { - "$ref": "#/$defs/NotHeldEntry" - } - ] - }, - "title": "Nodes", - "type": "array" - } - }, - "required": ["nodes"], - "title": "NodesResponse", - "type": "object" - }, - "resolve_request": { - "additionalProperties": false, - "description": "`texts`: 1 to 25 strings resolved as one batch. Each is stripped of surrounding\nwhitespace and must then be 1 to 200 characters.\n\n`kinds` admits only candidates of those kinds. It defaults to `DEFAULT_RESOLVE_KINDS`, so a\nrequest that omits it equals one naming those eight. `code` searches the code index's labels\nand synonyms, and `code_system` narrows codes to one system's graph id\n(`CRSPurposeCodeEnum`); naming `code_system` without `code` in `kinds` is refused at\n`/code_system`. `expand: [\"members\"]` inlines a group's or region's members.", - "properties": { - "code_system": { - "anyOf": [ - { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Code System" - }, - "expand": { - "default": [], - "items": { - "const": "members", - "type": "string" - }, - "title": "Expand", - "type": "array" - }, - "kinds": { - "default": [ - "country", - "continent", - "subregion", - "continental_union", - "organisation", - "aggregate", - "place_other", - "group" - ], - "items": { - "enum": [ - "country", - "continent", - "subregion", - "continental_union", - "organisation", - "aggregate", - "place_other", - "group", - "code", - "other" - ], - "type": "string" - }, - "minItems": 1, - "title": "Kinds", - "type": "array" - }, - "texts": { - "items": { - "maxLength": 200, - "minLength": 1, - "type": "string" - }, - "maxItems": 25, - "minItems": 1, - "title": "Texts", - "type": "array" - } - }, - "required": ["texts"], - "title": "GraphResolveRequest", - "type": "object" - }, - "resolve_response": { - "$defs": { - "EntityRef": { - "description": "A reference with its kind. `kind` is `null` only for a `not_held` entity, which the\ngraph holds no node to classify.", - "properties": { - "kind": { - "anyOf": [ - { - "enum": [ - "country", - "continent", - "subregion", - "continental_union", - "place_other", - "aggregate", - "organisation", - "group", - "stat_var", - "code_system", - "code", - "unit", - "source", - "dataset", - "provenance", - "other" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Kind" - }, - "label": { - "title": "Label", - "type": "string" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label", "kind"], - "title": "GraphResolveEntity", - "type": "object" - }, - "Matched": { - "description": "The listed text the input matched, and whether it is the entity's name, an alias, or a\ncode. A phrase naming a group's members (\"EU countries\") matches as `alias`.", - "properties": { - "as": { - "enum": ["name", "alias", "code"], - "title": "As", - "type": "string" - }, - "text": { - "title": "Text", - "type": "string" - } - }, - "required": ["text", "as"], - "title": "GraphResolveMatched", - "type": "object" - }, - "NodeRef": { - "description": "A graph reference. `label` is `null` when the target is not held, carries no `name`, or\ncarries only a `name` equal to its own dcid.", - "properties": { - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label"], - "title": "GraphNodeRef", - "type": "object" - }, - "Projection": { - "description": "A named cut of a membership by member class.", - "enum": [ - "countries", - "other_states", - "territories", - "former_states", - "institutions" - ], - "title": "Projection", - "type": "string" - }, - "ResolveResult": { - "description": "One text's outcome. `input` echoes the text; fields a status does not use are `null`.\n\n- `resolved`: `entity`, `matched` and `reading`. A group or region adds `projection`,\n `member_count` and `members`: the list route's link, or the members inline (at most\n 256) when the request expanded them. The three are `null` for a region the membership\n policy does not hold.\n- `ambiguous`: `candidates`, in the resolver's order.\n- `not_found`: `near_matches`, hints for a person; a program should pick none by id.\n- `not_held`: `entity`, the id the resolver named, which does not open on the node route.\n\n`reading` is `members` when the text names a group's members (\"EU countries\"), else\n`entity`.", - "properties": { - "candidates": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/EntityRef" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Candidates" - }, - "entity": { - "anyOf": [ - { - "$ref": "#/$defs/EntityRef" - }, - { - "type": "null" - } - ], - "default": null - }, - "input": { - "title": "Input", - "type": "string" - }, - "matched": { - "anyOf": [ - { - "$ref": "#/$defs/Matched" - }, - { - "type": "null" - } - ], - "default": null - }, - "member_count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Member Count" - }, - "members": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/$defs/NodeRef" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Members" - }, - "near_matches": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/EntityRef" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Near Matches" - }, - "projection": { - "anyOf": [ - { - "$ref": "#/$defs/Projection" - }, - { - "type": "null" - } - ], - "default": null - }, - "reading": { - "anyOf": [ - { - "enum": ["members", "entity"], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Reading" - }, - "status": { - "enum": ["resolved", "ambiguous", "not_found", "not_held"], - "title": "Status", - "type": "string" - } - }, - "required": [ - "input", - "status", - "entity", - "matched", - "reading", - "projection", - "member_count", - "members", - "candidates", - "near_matches" - ], - "title": "GraphResolveResult", - "type": "object" - } - }, - "description": "`results` is index-aligned with the request's `texts`.", - "properties": { - "results": { - "items": { - "$ref": "#/$defs/ResolveResult" - }, - "title": "Results", - "type": "array" - } - }, - "required": ["results"], - "title": "GraphResolveResponse", - "type": "object" - }, - "roots_response": { - "$defs": { - "RootEntry": { - "description": "A place to start listing from, with the length of its list.", - "properties": { - "count": { - "description": "codes for a code system, members under the default projection for a group or region, Datasets for a source", - "title": "Count", - "type": "integer" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label", "count"], - "title": "GraphRootEntry", - "type": "object" - } - }, - "description": "`GET /api/graph/roots`: where to start listing. Each list is ordered by dcid.", - "properties": { - "built_from": { - "additionalProperties": { - "type": "string" - }, - "description": "the build of each table the document read", - "propertyNames": { - "enum": ["code_index", "origin_catalogue", "coverage_index", "membership"] - }, - "title": "Built From", - "type": "object" - }, - "code_systems": { - "items": { - "$ref": "#/$defs/RootEntry" - }, - "title": "Code Systems", - "type": "array" - }, - "groups": { - "items": { - "$ref": "#/$defs/RootEntry" - }, - "title": "Groups", - "type": "array" - }, - "regions": { - "items": { - "$ref": "#/$defs/RootEntry" - }, - "title": "Regions", - "type": "array" - }, - "sources": { - "items": { - "$ref": "#/$defs/RootEntry" - }, - "title": "Sources", - "type": "array" - } - }, - "required": ["code_systems", "groups", "regions", "sources", "built_from"], - "title": "GraphRoots", - "type": "object" - }, - "source_catalogue_response": { - "$defs": { - "DataCommonsRef": { - "description": "The Data Commons that serves a fact. `id` is the wire enum; `label` its display name.", - "properties": { - "id": { - "enum": ["dfo", "base", "un_system"], - "title": "Id", - "type": "string" - }, - "label": { - "title": "Label", - "type": "string" - } - }, - "required": ["id", "label"], - "title": "GraphDataCommonsRef", - "type": "object" - }, - "DatasetEntry": { - "description": "A Dataset and the admitted provenances that belong to it. A provenance with\nno Dataset forms its own entry, keyed by the provenance, so `ref` and `label`\nare the provenance's and `provenances` holds that one provenance.\n\nThe card facts (`description`, `license_type`, `update_cadence`,\n`years_covered`, `source_url`) equal the same members of\n`GET /api/graph/documentation/{ref}` for the graph the loaded catalogue\nbuild read. They are null on UN System Data Commons entries.", - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "its sole Provenance's description, as `details.description`; null when it has several", - "title": "Description" - }, - "label": { - "title": "Label", - "type": "string" - }, - "license_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "licence label every Provenance agrees on, as `license.type`; null when they disagree or one has none", - "title": "License Type" - }, - "provenances": { - "items": { - "$ref": "#/$defs/ProvenanceEntry" - }, - "title": "Provenances", - "type": "array" - }, - "ref": { - "title": "Ref", - "type": "string" - }, - "source_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the publication's page, as `details.source_url`: the Dataset's url, else its sole Provenance's", - "title": "Source Url" - }, - "update_cadence": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "release cadence label every Provenance agrees on, as `license.update_cadence`; null when they disagree or one has none", - "title": "Update Cadence" - }, - "years_covered": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "`YYYY` or `YYYY-YYYY` span of its Provenances' observation dates, as `scope_and_units.years_covered`; null when either end is unknown", - "title": "Years Covered" - } - }, - "required": [ - "ref", - "label", - "provenances", - "description", - "license_type", - "update_cadence", - "years_covered", - "source_url" - ], - "title": "DatasetEntry", - "type": "object" - }, - "Exclusion": { - "description": "A provenance a base source holds that this deployment does not admit, because\nanother Data Commons supersedes it.", - "properties": { - "reason": { - "description": "superseded_by_dfo: an upstream copy of a dataset DFO imports. superseded_by_un_system: a UN System Data Commons agency's series replace it", - "enum": ["superseded_by_dfo", "superseded_by_un_system"], - "title": "Reason", - "type": "string" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "reason"], - "title": "Exclusion", - "type": "object" - }, - "ProvenanceEntry": { - "description": "One admitted provenance: its dcid and the provenance table's name for it.", - "properties": { - "label": { - "title": "Label", - "type": "string" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label"], - "title": "ProvenanceEntry", - "type": "object" - }, - "SourceCatalogueEntry": { - "description": "One source on one Data Commons, with the datasets this deployment\nadmits under it and the provenances it excludes. DFO provenances and UN\nagencies exclude nothing here.", - "properties": { - "data_commons": { - "$ref": "#/$defs/DataCommonsRef" - }, - "datasets": { - "items": { - "$ref": "#/$defs/DatasetEntry" - }, - "title": "Datasets", - "type": "array" - }, - "exclusions": { - "items": { - "$ref": "#/$defs/Exclusion" - }, - "title": "Exclusions", - "type": "array" - }, - "source": { - "$ref": "#/$defs/SourceNodeRef" - } - }, - "required": ["source", "data_commons", "datasets", "exclusions"], - "title": "SourceCatalogueEntry", - "type": "object" - }, - "SourceNodeRef": { - "description": "A Data Commons `Source` node (`dc/s/*`). On the UN System Data Commons, where every\nagency sits under one source, it is the agency provenance (`undata/p/*`).", - "properties": { - "label": { - "title": "Label", - "type": "string" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label"], - "title": "GraphSourceNodeRef", - "type": "object" - } - }, - "description": "The full `GET /api/graph/sources` response: DFO's sources, then the\norigin registry's base Data Commons entries and UN System Data Commons\nagencies in registry order, each with the provenances the request's\norigin catalogue admits under it, grouped by Dataset. UN agencies are left\nout when the UN origin is off. The response has no count and no pagination.", - "properties": { - "sources": { - "items": { - "$ref": "#/$defs/SourceCatalogueEntry" - }, - "title": "Sources", - "type": "array" - } - }, - "required": ["sources"], - "title": "SourceCatalogue", - "type": "object" - }, - "source_documentation_response": { - "$defs": { - "ChangeEntry": { - "description": "One change in a release.", - "properties": { - "kind": { - "description": "the Keep a Changelog group the change sits under", - "enum": [ - "added", - "changed", - "deprecated", - "removed", - "fixed", - "security" - ], - "title": "Kind", - "type": "string" - }, - "note": { - "description": "the change, as CommonMark", - "title": "Note", - "type": "string" - } - }, - "required": ["kind", "note"], - "title": "ChangeEntry", - "type": "object" - }, - "DataCommonsRef": { - "description": "The Data Commons that serves a fact. `id` is the wire enum; `label` its display name.", - "properties": { - "id": { - "enum": ["dfo", "base", "un_system"], - "title": "Id", - "type": "string" - }, - "label": { - "title": "Label", - "type": "string" - } - }, - "required": ["id", "label"], - "title": "GraphDataCommonsRef", - "type": "object" - }, - "DocumentErrorEntry": { - "description": "A Markdown document the source carries that this door could not serve.\nThe member it fills is empty and named in `gaps`.", - "properties": { - "line": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "1-based line in the decoded document where the problem starts, or null", - "title": "Line" - }, - "member": { - "description": "the wire path the document fills", - "enum": [ - "transformation_log", - "version_history", - "methodology.description", - "data_notes" - ], - "title": "Member", - "type": "string" - }, - "reason": { - "description": "the template rule the document breaks", - "enum": [ - "undecodable", - "empty_document", - "content_outside_section", - "unexpected_heading_level", - "heading_not_plain_text", - "empty_heading", - "empty_section", - "disallowed_block", - "raw_html", - "hard_break", - "invalid_release_heading", - "missing_release_title", - "release_dates_not_descending", - "duplicate_release_date", - "unknown_change_kind", - "duplicate_change_kind", - "not_single_bullet_list", - "list_item_not_single_paragraph", - "document_too_large", - "nesting_too_deep", - "conflicting_values" - ], - "title": "Reason", - "type": "string" - } - }, - "required": ["member", "line", "reason"], - "title": "DocumentErrorEntry", - "type": "object" - }, - "Methodology": { - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "how the publisher collects and compiles the data, as CommonMark", - "title": "Description" - } - }, - "required": ["description"], - "title": "Methodology", - "type": "object" - }, - "ScopeAndUnits": { - "description": "`countries` is always `[]`. `years_covered` is filled where the graph\nhas it. `currencies` lists every currency unit the coverage index gives the\nsource's Provenances, sorted, and `granularity` is filled where it gives\none period and axis pair for the source.", - "properties": { - "countries": { - "items": { - "type": "string" - }, - "title": "Countries", - "type": "array" - }, - "currencies": { - "items": { - "type": "string" - }, - "title": "Currencies", - "type": "array" - }, - "granularity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Granularity" - }, - "years_covered": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Years Covered" - } - }, - "required": ["countries", "years_covered", "granularity", "currencies"], - "title": "ScopeAndUnits", - "type": "object" - }, - "SourceDetails": { - "properties": { - "citation": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the source's `licenseAttribution` text", - "title": "Citation" - }, - "data_commons": { - "anyOf": [ - { - "$ref": "#/$defs/DataCommonsRef" - }, - { - "type": "null" - } - ], - "description": "the Data Commons serving the documented Provenances, null unless the origin catalogue admits every one of them and they share one" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "name_of_source": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name Of Source" - }, - "publisher": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Publisher" - }, - "source": { - "anyOf": [ - { - "$ref": "#/$defs/SourceNodeRef" - }, - { - "type": "null" - } - ], - "description": "the origin registry's source for the documented Provenances, null unless the origin catalogue admits every one of them and they share one" - }, - "source_data_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the file the pipeline downloads, the source's `sourceDataUrl`", - "title": "Source Data Url" - }, - "source_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Source Url" - } - }, - "required": [ - "name_of_source", - "publisher", - "source", - "data_commons", - "description", - "source_url", - "source_data_url", - "citation" - ], - "title": "SourceDetails", - "type": "object" - }, - "SourceLicense": { - "properties": { - "last_update": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Last Update" - }, - "next_expected_update": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Next Expected Update" - }, - "type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Type" - }, - "update_cadence": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Update Cadence" - }, - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "the licence text on the publisher's site, the source's `license`", - "title": "Url" - } - }, - "required": [ - "type", - "url", - "update_cadence", - "last_update", - "next_expected_update" - ], - "title": "SourceLicense", - "type": "object" - }, - "SourceNodeRef": { - "description": "A Data Commons `Source` node (`dc/s/*`). On the UN System Data Commons, where every\nagency sits under one source, it is the agency provenance (`undata/p/*`).", - "properties": { - "label": { - "title": "Label", - "type": "string" - }, - "ref": { - "title": "Ref", - "type": "string" - } - }, - "required": ["ref", "label"], - "title": "GraphSourceNodeRef", - "type": "object" - }, - "TransformationLogEntry": { - "description": "One step of the pipeline's transformations, in pipeline order.", - "properties": { - "description": { - "description": "what the step does, as CommonMark", - "title": "Description", - "type": "string" - }, - "transformation": { - "description": "the step title, plain text", - "title": "Transformation", - "type": "string" - } - }, - "required": ["transformation", "description"], - "title": "TransformationLogEntry", - "type": "object" - }, - "Usage": { - "description": "`api_access`/`python` are `None` for every source -- an absent\nmember, not a stand-in object -- named in `gaps` alongside\nevery other absent member. Built by `graph_api.usage.usage_document`,\nnever constructed inline at a call site.", - "properties": { - "api_access": { - "anyOf": [ - { - "$ref": "#/$defs/UsageEntry" - }, - { - "type": "null" - } - ], - "default": null - }, - "python": { - "anyOf": [ - { - "$ref": "#/$defs/UsageEntry" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "required": ["api_access", "python"], - "title": "Usage", - "type": "object" - }, - "UsageEntry": { - "description": "The shape a non-null `Usage.api_access`/`.python` entry takes. Both\nof its own members are always `None`, and no dcid produces an entry at\nall -- `graph_api.usage`'s own docstring is the record of why.", - "properties": { - "code": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Code" - }, - "documentation_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Documentation Url" - } - }, - "required": ["documentation_url", "code"], - "title": "UsageEntry", - "type": "object" - }, - "VersionHistoryEntry": { - "description": "One release of the pipeline's changelog, newest first.", - "properties": { - "changes": { - "description": "every change, group by group in order", - "items": { - "$ref": "#/$defs/ChangeEntry" - }, - "title": "Changes", - "type": "array" - }, - "date": { - "description": "the release date, YYYY-MM-DD", - "title": "Date", - "type": "string" - }, - "title": { - "description": "the release title, plain text", - "title": "Title", - "type": "string" - } - }, - "required": ["date", "title", "changes"], - "title": "VersionHistoryEntry", - "type": "object" - } - }, - "description": "The full `GET /api/graph/documentation/{dcid}` response.", - "properties": { - "data_notes": { - "description": "one caveat per entry, as CommonMark", - "items": { - "type": "string" - }, - "title": "Data Notes", - "type": "array" - }, - "details": { - "$ref": "#/$defs/SourceDetails" - }, - "document_errors": { - "description": "each Markdown document the source carries but this door could not serve", - "items": { - "$ref": "#/$defs/DocumentErrorEntry" - }, - "title": "Document Errors", - "type": "array" - }, - "gaps": { - "description": "every member path that is null or empty for this source, in spec order, e.g. 'details.citation', 'scope_and_units.countries'", - "items": { - "type": "string" - }, - "title": "Gaps", - "type": "array" - }, - "license": { - "$ref": "#/$defs/SourceLicense" - }, - "methodology": { - "$ref": "#/$defs/Methodology" - }, - "scope_and_units": { - "$ref": "#/$defs/ScopeAndUnits" - }, - "transformation_log": { - "items": { - "$ref": "#/$defs/TransformationLogEntry" - }, - "title": "Transformation Log", - "type": "array" - }, - "usage": { - "$ref": "#/$defs/Usage" - }, - "version_history": { - "items": { - "$ref": "#/$defs/VersionHistoryEntry" - }, - "title": "Version History", - "type": "array" - } - }, - "required": [ - "details", - "license", - "scope_and_units", - "methodology", - "data_notes", - "usage", - "transformation_log", - "version_history", - "gaps", - "document_errors" - ], - "title": "SourceDocumentation", - "type": "object" - } - } -} diff --git a/src/lib/graph/schema.d.ts b/src/lib/graph/schema.d.ts deleted file mode 100644 index 89a4240..0000000 --- a/src/lib/graph/schema.d.ts +++ /dev/null @@ -1,2129 +0,0 @@ -// Generated from src/lib/graph/graph-api-schema.json by `pnpm graph:types`. -// Do not edit by hand. - -export type paths = Record; -export type webhooks = Record; -export interface components { - schemas: { - /** - * HealthResponse - * @description `GET /healthz`'s own response body, shared across every surface. - */ - health_response: { - /** - * Build - * @description the commit sha the image was built from, the same value the x-dfo-commit response header carries, "unknown" on a checkout run with no DFO_BUILD_COMMIT set - */ - build: string; - /** - * Capability Version - * @description this surface's own capability_version token - */ - capability_version: string; - /** - * Status - * @description always "ok" for a 200 response - * @constant - */ - status: 'ok'; - }; - /** - * GraphAvailabilitySource - * @description A source that holds the variable: its provenance, labelled with the catalogue's name. - */ - AvailabilitySource: { - /** - * Documentation - * @description the path of the provenance's source document - */ - documentation: string; - /** Label */ - label: string | null; - /** Ref */ - ref: string; - }; - /** - * GraphCodeItem - * @description A code in a `codes` or `children` list, with its code and its parent's dcid. - */ - CodeItem: { - /** Code */ - code: string; - /** Label */ - label: string | null; - /** - * Parent - * @description the parent code's dcid; null for a top-level code - */ - parent: string | null; - /** Ref */ - ref: string; - }; - /** - * GraphFacetItem - * @description One facet of a variable, as the `facets` list relation serves it. `ref` is the facet id, - * which hashes the facet's metadata. - */ - FacetItem: { - /** First Year */ - first_year: string | null; - /** - * Label - * @default null - */ - label: null; - /** Last Year */ - last_year: string | null; - measurement_method: components['schemas']['NodeRef'] | null; - /** Observation Period */ - observation_period: string | null; - /** Observations */ - observations: number; - /** - * Places - * @description null for a role-bearing variable - */ - places: number | null; - /** Ref */ - ref: string; - /** Scaling */ - scaling: string | null; - source: components['schemas']['AvailabilitySource']; - unit: components['schemas']['NodeRef'] | null; - }; - /** - * GraphMemberItem - * @description One item of a list: its dcid and the owner's label for it, `null` where the owner holds - * no name. - */ - MemberItem: { - /** Label */ - label: string | null; - /** Ref */ - ref: string; - }; - /** - * GraphNodeRef - * @description A graph reference. `label` is `null` when the target is not held, carries no `name`, or - * carries only a `name` equal to its own dcid. - */ - NodeRef: { - /** Label */ - label: string | null; - /** Ref */ - ref: string; - }; - /** - * Projection - * @description A named cut of a membership by member class. - * @enum {string} - */ - Projection: 'countries' | 'other_states' | 'territories' | 'former_states' | 'institutions'; - /** - * GraphMembersPage - * @description One page of `GET /api/graph/node/{dcid}/members`. - */ - members_response: { - /** - * As Of - * @description the date membership was read at; null for today - */ - as_of: string | null; - /** - * Built From - * @description the build of each table the list read - */ - built_from: { - [key: string]: string; - }; - /** - * Complete - * @description whether the walk reached the end of a list the owner holds in full - */ - complete: boolean; - /** - * Gaps - * @description the code of each way the list is partial - */ - gaps: string[]; - /** Items */ - items: ( - | components['schemas']['CodeItem'] - | components['schemas']['FacetItem'] - | components['schemas']['MemberItem'] - )[]; - /** - * Next Cursor - * @description the cursor for the next page; null when this page ends the list - */ - next_cursor: string | null; - /** @description the membership projection listed; null for a relation that takes none */ - projection: components['schemas']['Projection'] | null; - /** - * Ref - * @description the node whose list this is - */ - ref: string; - /** - * Relation - * @enum {string} - */ - relation: - | 'codes' - | 'children' - | 'datasets' - | 'variables' - | 'members' - | 'countries' - | 'facets'; - /** - * Total - * @description the list's length; null where the list is partial - */ - total: number | null; - }; - /** AggregateNode */ - AggregateNode: { - aggregate: components['schemas']['PartyBlock']; - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'aggregate'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphMembershipAuthority - * @description Where a membership's rows come from, with the data version that reproduces them. - */ - Authority: { - /** Name */ - name: string; - /** Version */ - version: string; - }; - /** - * GraphAvailability - * @description Where DFO holds observations of a variable. `sources: []` means it verifiably holds - * none. - */ - Availability: { - /** @description null when no facet dispatches a request naming nothing */ - default: components['schemas']['AvailabilityDefault'] | null; - /** - * More Sources - * @description the sources past the first ten - */ - more_sources: number; - /** - * Scope - * @default dfo - * @constant - */ - scope: 'dfo'; - /** Sources */ - sources: components['schemas']['SourceAvailability'][]; - summary: components['schemas']['AvailabilitySummary']; - }; - /** - * GraphAvailabilityDefault - * @description What a data request naming no place, years or facet receives. - */ - AvailabilityDefault: { - /** - * Facets - * @description the facet ids the request is served, in order - */ - facets: string[]; - /** First Year */ - first_year: string | null; - /** Last Year */ - last_year: string | null; - measurement_method: components['schemas']['NodeRef'] | null; - /** Observation Period */ - observation_period: string | null; - /** - * Places - * @description null for a role-bearing variable - */ - places: number | null; - source: components['schemas']['AvailabilitySource'] | null; - unit: components['schemas']['NodeRef'] | null; - /** - * Without Data - * @description the places, or parties, holding the variable that the default leaves without data - */ - without_data: number; - }; - /** - * GraphAvailabilitySummary - * @description The variable across every source. - */ - AvailabilitySummary: { - /** - * Entities - * @description the entities covered per graph role; null for any other variable - */ - entities: { - [key: string]: components['schemas']['EntityCounts']; - } | null; - /** Facets */ - facets: number; - /** First Year */ - first_year: string | null; - /** Last Year */ - last_year: string | null; - /** - * Places - * @description the places covered by kind; null for a role-bearing variable, and while the places' kinds are unread (a transient gap) - */ - places: { - [key: string]: number; - } | null; - /** Sources */ - sources: number; - }; - /** - * GraphAvailabilityBinding - * @description The `scope.facets` values that request a series from Datasets, in its leading unit. - */ - Binding: { - /** Measurementmethod */ - measurementMethod: string | null; - /** Provenance */ - provenance: string; - /** Unit */ - unit: string | null; - }; - /** - * GraphCodeBlock - * @description A code's detail block. - */ - CodeBlock: { - /** Code */ - code: string; - /** @description the system's graph node, such as `CRSPurposeCodeEnum` */ - code_system: components['schemas']['NodeRef'] | null; - /** @description the code it specialises; null for a root */ - parent: components['schemas']['NodeRef'] | null; - /** - * Property - * @description the constraint property the code is a value of - */ - property: string; - /** Synonyms */ - synonyms: string[]; - }; - /** CodeNode */ - CodeNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - code: components['schemas']['CodeBlock']; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'code'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphCodeSystemBlock - * @description A code system's detail block. - */ - CodeSystemBlock: { - /** - * Code Count - * @description how many codes the code index holds for the system - */ - code_count: number; - declarations: components['schemas']['CodeSystemDeclarations']; - /** - * Plain Name - * @description the property's plain name, such as `sector` - */ - plain_name: string; - /** - * Property - * @description the constraint property the system governs - */ - property: string; - }; - /** - * GraphCodeSystemDeclarations - * @description The rules a code system declares (`code_index.types.CodeSystemDeclaration`). - */ - CodeSystemDeclarations: { - /** - * Absent Reading - * @description how a variable that does not declare the property reads - * @enum {string} - */ - absent_reading: 'not_declared' | 'aggregate'; - /** - * Disjoint From Children - * @description whether a parent code's records sit apart from its children's, so a parent's total is its own records plus its descendants' (CRS purposes); false where a parent row already totals its children (DAC1 aid types) - */ - disjoint_from_children: boolean; - /** - * Hierarchical - * @description whether its codes nest through specializationOf - */ - hierarchical: boolean; - /** - * Summable - * @description whether sibling values can rebuild a parent total - */ - summable: boolean; - }; - /** CodeSystemNode */ - CodeSystemNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - code_system: components['schemas']['CodeSystemBlock']; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'code_system'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphDefinitionConstraint - * @description One constraint: the property and the value the variable fixes it at. - */ - Constraint: { - property: components['schemas']['NodeRef']; - value: components['schemas']['NodeRef']; - }; - /** ContinentNode */ - ContinentNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - continent: components['schemas']['PlaceBlock']; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'continent'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** ContinentalUnionNode */ - ContinentalUnionNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - continental_union: components['schemas']['PlaceBlock']; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'continental_union'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** CountryNode */ - CountryNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - country: components['schemas']['PlaceBlock']; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'country'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphDataCommonsRef - * @description The Data Commons that serves a fact. `id` is the wire enum; `label` its display name. - */ - DataCommonsRef: { - /** - * Id - * @enum {string} - */ - id: 'dfo' | 'base' | 'un_system'; - /** Label */ - label: string; - }; - /** DatasetNode */ - DatasetNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - dataset: components['schemas']['SourceBlock']; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'dataset'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphDefinitionText - * @description The graph's prose for one part of a definition. - */ - DefinitionText: { - /** @description the part the text defines */ - of: components['schemas']['NodeRef']; - /** Text */ - text: string; - }; - /** - * GraphAvailabilityEntityCounts - * @description The distinct entities in one role, split by their kind in the code index. - */ - EntityCounts: { - /** - * Parties - * @description entities with figures of their own - */ - parties: number; - /** - * Residual - * @description entities holding figures no other entity does - */ - residual: number; - /** - * Totals - * @description entities that hold other entities' figures - */ - totals: number; - /** - * Unclassified - * @description entities the code index does not list - */ - unclassified: number; - }; - /** - * Freshness - * @description A source's own refresh cadence, read from DCP provenance arcs that imports populate - * unevenly: `dc/base/WHO_COVID19` carries both `sourceReleaseFrequency` and - * `automaticRefreshType`, `dc/base/BIS_CentralBankPolicyRate` only the second, and - * `dc/base/WHO_GHO` neither. Nullability is the contract. - * - * `release_frequency` is the raw `sourceReleaseFrequency` ISO 8601 duration (`P1M`); - * `release_frequency_label` is `materialise.cadence.cadence_label`'s display label for it - * (`Monthly`), or the raw token when that module does not recognise it. - * - * `refresh_type` is `automaticRefreshType`'s value: how this deployment refreshes the - * source, such as `GCPCloudRunAutomaticRefresh`, or the sentinel `NoAutomaticRefresh`. - */ - Freshness: { - /** Refresh Type */ - refresh_type: string | null; - /** Release Frequency */ - release_frequency: string | null; - /** Release Frequency Label */ - release_frequency_label: string | null; - }; - /** GroupNode */ - GroupNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - group: components['schemas']['MembershipBlock']; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'group'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphDefinitionMeasure - * @description What the variable counts or sums, as the code index names it. - */ - Measure: { - /** - * Label - * @description the measure's noun phrase, such as `net disbursements` - */ - label: string; - }; - /** - * GraphMembershipBlock - * @description A group's or region's membership under its declared authority. - */ - MembershipBlock: { - /** - * As Of - * @description the date the read asked for; null for today - */ - as_of: string | null; - authority: components['schemas']['Authority']; - /** - * Effective Date - * @description the latest membership change on or before the date; null for an undated membership - */ - effective_date: string | null; - /** Projections */ - projections: components['schemas']['ProjectionCount'][]; - }; - /** - * GraphAvailabilityNationalCurrency - * @description The units that are each covered place's own currency. - */ - NationalCurrency: { - /** Currencies */ - currencies: number; - /** Places */ - places: number; - }; - /** OrganisationNode */ - OrganisationNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'organisation'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - organisation: components['schemas']['PartyBlock']; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * OtherNode - * @description A node no other kind fits. `types` and `description` carry what the graph holds. - */ - OtherNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'other'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphPartyBlock - * @description An organisation's or aggregate's detail block. - */ - PartyBlock: { - /** - * Roles - * @description the role properties a node of these types can fill, by the role ranges the graph declares (rangeIncludes and its subClassOf closure); null when that read failed - */ - roles: string[] | null; - }; - /** - * GraphPlaceBlock - * @description A place's detail block. `membership` is set for a region the membership policy holds. - */ - PlaceBlock: { - /** - * Contained In - * @description the containedInPlace targets - */ - contained_in: components['schemas']['NodeRef'][]; - membership: components['schemas']['MembershipBlock'] | null; - }; - /** PlaceOtherNode */ - PlaceOtherNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'place_other'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - place_other: components['schemas']['PlaceBlock']; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphPriceBasis - * @description Current or constant prices, with the base year of constant prices when known. - */ - PriceBasisWords: { - /** Base Year */ - base_year: number | null; - /** - * Prices - * @enum {string} - */ - prices: 'current' | 'constant'; - }; - /** - * GraphProjectionCount - * @description One projection of a membership and its member count at the block's date. - */ - ProjectionCount: { - /** - * Complete - * @description false when the authority does not record the projection, or is known to hold it only in part (`membership_incomplete`) - */ - complete: boolean; - /** - * Count - * @description members under the projection; null when the authority does not record it - */ - count: number | null; - projection: components['schemas']['Projection']; - }; - /** ProvenanceNode */ - ProvenanceNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'provenance'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - provenance: components['schemas']['SourceBlock']; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphAvailabilitySeries - * @description A source's facets sharing a measurement method, period and scaling. - */ - Series: { - binding: components['schemas']['Binding']; - /** First Year */ - first_year: string | null; - /** Last Year */ - last_year: string | null; - /** @description null when no facet states a method: no stated method */ - measurement_method: components['schemas']['NodeRef'] | null; - /** Observation Period */ - observation_period: string | null; - /** Observations */ - observations: number; - /** - * Places - * @description null for a role-bearing variable - */ - places: number | null; - /** Scaling */ - scaling: string | null; - units: components['schemas']['UnitsFold']; - }; - /** - * GraphAvailabilitySourceEntry - * @description One source's facets of the variable, and their series. - */ - SourceAvailability: { - /** - * Entities - * @description the entities covered per graph role; null for any other variable - */ - entities: { - [key: string]: components['schemas']['EntityCounts']; - } | null; - /** Facets */ - facets: number; - /** First Year */ - first_year: string | null; - /** Last Year */ - last_year: string | null; - /** - * Places - * @description the places covered by kind; null for a role-bearing variable, and while the places' kinds are unread (a transient gap) - */ - places: { - [key: string]: number; - } | null; - /** Series */ - series: components['schemas']['Series'][]; - source: components['schemas']['AvailabilitySource']; - }; - /** - * GraphSourceBlock - * @description A Source, Dataset or Provenance's detail block. `documentation` is the path of the - * node's source document on this API. - */ - SourceBlock: { - /** @description the Data Commons serving this provenance, from the origin catalogue; null for a provenance the catalogue does not admit, a Dataset, or a Source */ - data_commons: components['schemas']['DataCommonsRef'] | null; - /** - * Description Url - * @description the descriptionUrl arc's value - */ - description_url: string | null; - /** - * Documentation - * @description the path of the node's source document - */ - documentation: string; - /** - * Earliest Observation Date - * @description the earliestObservationDate arc's value - */ - earliest_observation_date: string | null; - freshness: components['schemas']['Freshness']; - /** - * Last Data Refresh Date - * @description the lastDataRefreshDate arc's value - */ - last_data_refresh_date: string | null; - /** - * Latest Observation Date - * @description the latestObservationDate arc's value - */ - latest_observation_date: string | null; - /** License */ - license: string | null; - /** License Type */ - license_type: string | null; - /** - * Next Data Refresh Date - * @description the nextDataRefreshDate arc's value - */ - next_data_refresh_date: string | null; - /** @description the isPartOf arc's target; null when the node has no parent Dataset */ - parent: components['schemas']['NodeRef'] | null; - /** @description the source arc's target; null when the node carries no source arc */ - publisher: components['schemas']['NodeRef'] | null; - /** - * Source Data Url - * @description the sourceDataUrl arc's value - */ - source_data_url: string | null; - /** - * Transformation Logic Url - * @description the dataTransformationLogic arc's value: a URL to the transformation code - */ - transformation_logic_url: string | null; - /** Url */ - url: string | null; - }; - /** SourceNode */ - SourceNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'source'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - source: components['schemas']['SourceBlock']; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphStatVarBlock - * @description A variable's detail block: what it measures and where DFO holds observations of it. - */ - StatVarBlock: { - /** @description where DFO holds observations of the variable; null, and named in gaps, when its read failed or the coverage index is not loaded */ - availability: components['schemas']['Availability'] | null; - definition: components['schemas']['VariableDefinition']; - }; - /** StatVarNode */ - StatVarNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'stat_var'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - stat_var: components['schemas']['StatVarBlock']; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** SubregionNode */ - SubregionNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'subregion'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - subregion: components['schemas']['PlaceBlock']; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - }; - /** - * GraphUnitBlock - * @description A unit's detail block: the words `unit_words` names it by. - */ - UnitBlock: { - /** - * Changes Magnitude - * @description whether the unit changes what a number means, so a heading states it - */ - changes_magnitude: boolean; - /** - * Currency - * @description its ISO 4217 code when it is a currency - */ - currency: string | null; - /** - * Currency Noun - * @description the plural noun a currency reads as, such as `US dollars` - */ - currency_noun: string | null; - /** @description the price basis the unit itself states; null when it states none */ - price_basis: components['schemas']['PriceBasisWords'] | null; - /** @description currency, purchasing_power, percent, or other (`unit_words.unit_class`) */ - unit_class: components['schemas']['UnitKind']; - }; - /** - * GraphAvailabilityUnit - * @description One unit of a series. `ref` is `null` for facets that state no unit. - */ - UnitCount: { - /** Label */ - label: string | null; - /** - * Places - * @description the places its facets cover; null for a role-bearing variable - */ - places: number | null; - /** Ref */ - ref: string | null; - }; - /** @enum {string} */ - UnitKind: 'currency' | 'purchasing_power' | 'percent' | 'other' | 'none'; - /** UnitNode */ - UnitNode: { - /** - * Built From - * @description the build of each table the response read - */ - built_from: { - [key: string]: string; - }; - /** - * Description - * @description the node's prose in the graph; null when it has none - */ - description: string | null; - /** - * Gaps - * @description the member path of every value this door cannot know - */ - gaps: string[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - kind: 'unit'; - /** - * Label - * @description the node's name; for a Provenance, its parent Dataset's name where it has one. Null, and named in gaps, when the graph gives none - */ - label: string | null; - /** - * Links - * @description each list relation the node has, mapped to its route; empty when none - */ - links: { - [key: string]: string; - }; - /** - * Ref - * @description the node's dcid - */ - ref: string; - /** - * Types - * @description the node's typeOf values, as the graph holds them - */ - types: string[]; - unit: components['schemas']['UnitBlock']; - }; - /** - * GraphAvailabilityUnits - * @description A series' units: how many, the two covering the most places, and how many more. - */ - UnitsFold: { - /** Count */ - count: number; - /** Leading */ - leading: components['schemas']['UnitCount'][]; - /** More */ - more: number; - /** @description the units that are their places' national currency; null when none is */ - national_currency: components['schemas']['NationalCurrency'] | null; - }; - /** - * GraphVariableDefinition - * @description What a variable measures, part by part. - * - * `constraints` are ordered by property dcid: the code index holds a variable's constraints - * as a mapping with no declared order, and the graph's `constraintProperties` arc has none. - */ - VariableDefinition: { - /** - * Constraints - * @description each constraint the measure does not state, ordered by property dcid; null when the read of a variable's constraint values failed - */ - constraints: components['schemas']['Constraint'][] | null; - /** - * Definitions - * @description the graph's prose per part: the variable's own, then the measure's, then each constraint value's, in constraint order; a part with none is left out - */ - definitions: components['schemas']['DefinitionText'][]; - /** @description the measure the code index names; null when it names none */ - measure: components['schemas']['Measure'] | null; - /** @description the measuredProperty; null when the variable declares none */ - measured_property: components['schemas']['NodeRef'] | null; - /** @description the populationType; null when the variable declares none */ - population_type: components['schemas']['NodeRef'] | null; - /** - * Roles - * @description the entity roles the variable's observations carry, from its declared observationProperties, defaulting to observationAbout when it declares none - */ - roles: string[]; - /** - * Source Indicator Codes - * @description the sourceIndicatorCode arc's values: the codes naming the source series, as the publisher codes it (IMF WEO GDP carries NGDP and NGDPD); empty when it has none - */ - source_indicator_codes: string[]; - /** @description the statType; null when the variable declares none or its read failed */ - stat_type: components['schemas']['NodeRef'] | null; - }; - node_response: - | components['schemas']['StatVarNode'] - | components['schemas']['CodeSystemNode'] - | components['schemas']['CodeNode'] - | components['schemas']['GroupNode'] - | components['schemas']['CountryNode'] - | components['schemas']['ContinentNode'] - | components['schemas']['SubregionNode'] - | components['schemas']['ContinentalUnionNode'] - | components['schemas']['PlaceOtherNode'] - | components['schemas']['OrganisationNode'] - | components['schemas']['AggregateNode'] - | components['schemas']['UnitNode'] - | components['schemas']['SourceNode'] - | components['schemas']['DatasetNode'] - | components['schemas']['ProvenanceNode'] - | components['schemas']['OtherNode']; - /** - * GraphNodesRequest - * @description `dcids`: 1 to `NODES_MAX_DCIDS` node dcids read as one batch, in one DCP-read - * executor job. Each dcid matches the GET route's `{dcid}` path syntax and its 512-character - * ceiling. - */ - nodes_request: { - /** Dcids */ - dcids: string[]; - }; - /** - * GraphNotHeldEntry - * @description A batch entry for a dcid where `GET /api/graph/node/{dcid}` answers 404 - * `node_not_held`. - */ - NotHeldEntry: { - /** Ref */ - ref: string; - /** - * Status - * @default not_held - * @constant - */ - status: 'not_held'; - }; - /** - * NodesResponse - * @description `nodes` is index-aligned with the request's `dcids`: a repeated dcid repeats its - * entry. Each entry is the body `GET /api/graph/node/{dcid}` returns for that dcid, or a - * `not_held` entry where that route answers 404. - */ - nodes_response: { - /** Nodes */ - nodes: ( - | ( - | components['schemas']['StatVarNode'] - | components['schemas']['CodeSystemNode'] - | components['schemas']['CodeNode'] - | components['schemas']['GroupNode'] - | components['schemas']['CountryNode'] - | components['schemas']['ContinentNode'] - | components['schemas']['SubregionNode'] - | components['schemas']['ContinentalUnionNode'] - | components['schemas']['PlaceOtherNode'] - | components['schemas']['OrganisationNode'] - | components['schemas']['AggregateNode'] - | components['schemas']['UnitNode'] - | components['schemas']['SourceNode'] - | components['schemas']['DatasetNode'] - | components['schemas']['ProvenanceNode'] - | components['schemas']['OtherNode'] - ) - | components['schemas']['NotHeldEntry'] - )[]; - }; - /** - * GraphResolveRequest - * @description `texts`: 1 to 25 strings resolved as one batch. Each is stripped of surrounding - * whitespace and must then be 1 to 200 characters. - * - * `kinds` admits only candidates of those kinds. It defaults to `DEFAULT_RESOLVE_KINDS`, so a - * request that omits it equals one naming those eight. `code` searches the code index's labels - * and synonyms, and `code_system` narrows codes to one system's graph id - * (`CRSPurposeCodeEnum`); naming `code_system` without `code` in `kinds` is refused at - * `/code_system`. `expand: ["members"]` inlines a group's or region's members. - */ - resolve_request: { - /** - * Code System - * @default null - */ - code_system: string | null; - /** - * Expand - * @default [] - */ - expand: 'members'[]; - /** - * Kinds - * @default [ - * "country", - * "continent", - * "subregion", - * "continental_union", - * "organisation", - * "aggregate", - * "place_other", - * "group" - * ] - */ - kinds: ( - | 'country' - | 'continent' - | 'subregion' - | 'continental_union' - | 'organisation' - | 'aggregate' - | 'place_other' - | 'group' - | 'code' - | 'other' - )[]; - /** Texts */ - texts: string[]; - }; - /** - * GraphResolveEntity - * @description A reference with its kind. `kind` is `null` only for a `not_held` entity, which the - * graph holds no node to classify. - */ - EntityRef: { - /** Kind */ - kind: - | ( - | 'country' - | 'continent' - | 'subregion' - | 'continental_union' - | 'place_other' - | 'aggregate' - | 'organisation' - | 'group' - | 'stat_var' - | 'code_system' - | 'code' - | 'unit' - | 'source' - | 'dataset' - | 'provenance' - | 'other' - ) - | null; - /** Label */ - label: string; - /** Ref */ - ref: string; - }; - /** - * GraphResolveMatched - * @description The listed text the input matched, and whether it is the entity's name, an alias, or a - * code. A phrase naming a group's members ("EU countries") matches as `alias`. - */ - Matched: { - /** - * As - * @enum {string} - */ - as: 'name' | 'alias' | 'code'; - /** Text */ - text: string; - }; - /** - * GraphResolveResult - * @description One text's outcome. `input` echoes the text; fields a status does not use are `null`. - * - * - `resolved`: `entity`, `matched` and `reading`. A group or region adds `projection`, - * `member_count` and `members`: the list route's link, or the members inline (at most - * 256) when the request expanded them. The three are `null` for a region the membership - * policy does not hold. - * - `ambiguous`: `candidates`, in the resolver's order. - * - `not_found`: `near_matches`, hints for a person; a program should pick none by id. - * - `not_held`: `entity`, the id the resolver named, which does not open on the node route. - * - * `reading` is `members` when the text names a group's members ("EU countries"), else - * `entity`. - */ - ResolveResult: { - /** - * Candidates - * @default null - */ - candidates: components['schemas']['EntityRef'][] | null; - /** @default null */ - entity: components['schemas']['EntityRef'] | null; - /** Input */ - input: string; - /** @default null */ - matched: components['schemas']['Matched'] | null; - /** - * Member Count - * @default null - */ - member_count: number | null; - /** - * Members - * @default null - */ - members: string | components['schemas']['NodeRef'][] | null; - /** - * Near Matches - * @default null - */ - near_matches: components['schemas']['EntityRef'][] | null; - /** @default null */ - projection: components['schemas']['Projection'] | null; - /** - * Reading - * @default null - */ - reading: ('members' | 'entity') | null; - /** - * Status - * @enum {string} - */ - status: 'resolved' | 'ambiguous' | 'not_found' | 'not_held'; - }; - /** - * GraphResolveResponse - * @description `results` is index-aligned with the request's `texts`. - */ - resolve_response: { - /** Results */ - results: components['schemas']['ResolveResult'][]; - }; - /** - * GraphRootEntry - * @description A place to start listing from, with the length of its list. - */ - RootEntry: { - /** - * Count - * @description codes for a code system, members under the default projection for a group or region, Datasets for a source - */ - count: number; - /** Label */ - label: string | null; - /** Ref */ - ref: string; - }; - /** - * GraphRoots - * @description `GET /api/graph/roots`: where to start listing. Each list is ordered by dcid. - */ - roots_response: { - /** - * Built From - * @description the build of each table the document read - */ - built_from: { - [key: string]: string; - }; - /** Code Systems */ - code_systems: components['schemas']['RootEntry'][]; - /** Groups */ - groups: components['schemas']['RootEntry'][]; - /** Regions */ - regions: components['schemas']['RootEntry'][]; - /** Sources */ - sources: components['schemas']['RootEntry'][]; - }; - /** - * DatasetEntry - * @description A Dataset and the admitted provenances that belong to it. A provenance with - * no Dataset forms its own entry, keyed by the provenance, so `ref` and `label` - * are the provenance's and `provenances` holds that one provenance. - * - * The card facts (`description`, `license_type`, `update_cadence`, - * `years_covered`, `source_url`) equal the same members of - * `GET /api/graph/documentation/{ref}` for the graph the loaded catalogue - * build read. They are null on UN System Data Commons entries. - */ - DatasetEntry: { - /** - * Description - * @description its sole Provenance's description, as `details.description`; null when it has several - */ - description: string | null; - /** Label */ - label: string; - /** - * License Type - * @description licence label every Provenance agrees on, as `license.type`; null when they disagree or one has none - */ - license_type: string | null; - /** Provenances */ - provenances: components['schemas']['ProvenanceEntry'][]; - /** Ref */ - ref: string; - /** - * Source Url - * @description the publication's page, as `details.source_url`: the Dataset's url, else its sole Provenance's - */ - source_url: string | null; - /** - * Update Cadence - * @description release cadence label every Provenance agrees on, as `license.update_cadence`; null when they disagree or one has none - */ - update_cadence: string | null; - /** - * Years Covered - * @description `YYYY` or `YYYY-YYYY` span of its Provenances' observation dates, as `scope_and_units.years_covered`; null when either end is unknown - */ - years_covered: string | null; - }; - /** - * Exclusion - * @description A provenance a base source holds that this deployment does not admit, because - * another Data Commons supersedes it. - */ - Exclusion: { - /** - * Reason - * @description superseded_by_dfo: an upstream copy of a dataset DFO imports. superseded_by_un_system: a UN System Data Commons agency's series replace it - * @enum {string} - */ - reason: 'superseded_by_dfo' | 'superseded_by_un_system'; - /** Ref */ - ref: string; - }; - /** - * ProvenanceEntry - * @description One admitted provenance: its dcid and the provenance table's name for it. - */ - ProvenanceEntry: { - /** Label */ - label: string; - /** Ref */ - ref: string; - }; - /** - * SourceCatalogueEntry - * @description One source on one Data Commons, with the datasets this deployment - * admits under it and the provenances it excludes. DFO provenances and UN - * agencies exclude nothing here. - */ - SourceCatalogueEntry: { - data_commons: components['schemas']['DataCommonsRef']; - /** Datasets */ - datasets: components['schemas']['DatasetEntry'][]; - /** Exclusions */ - exclusions: components['schemas']['Exclusion'][]; - source: components['schemas']['SourceNodeRef']; - }; - /** - * GraphSourceNodeRef - * @description A Data Commons `Source` node (`dc/s/*`). On the UN System Data Commons, where every - * agency sits under one source, it is the agency provenance (`undata/p/*`). - */ - SourceNodeRef: { - /** Label */ - label: string; - /** Ref */ - ref: string; - }; - /** - * SourceCatalogue - * @description The full `GET /api/graph/sources` response: DFO's sources, then the - * origin registry's base Data Commons entries and UN System Data Commons - * agencies in registry order, each with the provenances the request's - * origin catalogue admits under it, grouped by Dataset. UN agencies are left - * out when the UN origin is off. The response has no count and no pagination. - */ - source_catalogue_response: { - /** Sources */ - sources: components['schemas']['SourceCatalogueEntry'][]; - }; - /** - * ChangeEntry - * @description One change in a release. - */ - ChangeEntry: { - /** - * Kind - * @description the Keep a Changelog group the change sits under - * @enum {string} - */ - kind: 'added' | 'changed' | 'deprecated' | 'removed' | 'fixed' | 'security'; - /** - * Note - * @description the change, as CommonMark - */ - note: string; - }; - /** - * DocumentErrorEntry - * @description A Markdown document the source carries that this door could not serve. - * The member it fills is empty and named in `gaps`. - */ - DocumentErrorEntry: { - /** - * Line - * @description 1-based line in the decoded document where the problem starts, or null - */ - line: number | null; - /** - * Member - * @description the wire path the document fills - * @enum {string} - */ - member: - 'transformation_log' | 'version_history' | 'methodology.description' | 'data_notes'; - /** - * Reason - * @description the template rule the document breaks - * @enum {string} - */ - reason: - | 'undecodable' - | 'empty_document' - | 'content_outside_section' - | 'unexpected_heading_level' - | 'heading_not_plain_text' - | 'empty_heading' - | 'empty_section' - | 'disallowed_block' - | 'raw_html' - | 'hard_break' - | 'invalid_release_heading' - | 'missing_release_title' - | 'release_dates_not_descending' - | 'duplicate_release_date' - | 'unknown_change_kind' - | 'duplicate_change_kind' - | 'not_single_bullet_list' - | 'list_item_not_single_paragraph' - | 'document_too_large' - | 'nesting_too_deep' - | 'conflicting_values'; - }; - /** Methodology */ - Methodology: { - /** - * Description - * @description how the publisher collects and compiles the data, as CommonMark - * @default null - */ - description: string | null; - }; - /** - * ScopeAndUnits - * @description `countries` is always `[]`. `years_covered` is filled where the graph - * has it. `currencies` lists every currency unit the coverage index gives the - * source's Provenances, sorted, and `granularity` is filled where it gives - * one period and axis pair for the source. - */ - ScopeAndUnits: { - /** Countries */ - countries: string[]; - /** Currencies */ - currencies: string[]; - /** - * Granularity - * @default null - */ - granularity: string | null; - /** - * Years Covered - * @default null - */ - years_covered: string | null; - }; - /** SourceDetails */ - SourceDetails: { - /** - * Citation - * @description the source's `licenseAttribution` text - */ - citation: string | null; - /** @description the Data Commons serving the documented Provenances, null unless the origin catalogue admits every one of them and they share one */ - data_commons: components['schemas']['DataCommonsRef'] | null; - /** Description */ - description: string | null; - /** Name Of Source */ - name_of_source: string | null; - /** Publisher */ - publisher: string | null; - /** @description the origin registry's source for the documented Provenances, null unless the origin catalogue admits every one of them and they share one */ - source: components['schemas']['SourceNodeRef'] | null; - /** - * Source Data Url - * @description the file the pipeline downloads, the source's `sourceDataUrl` - */ - source_data_url: string | null; - /** Source Url */ - source_url: string | null; - }; - /** SourceLicense */ - SourceLicense: { - /** Last Update */ - last_update: string | null; - /** Next Expected Update */ - next_expected_update: string | null; - /** Type */ - type: string | null; - /** Update Cadence */ - update_cadence: string | null; - /** - * Url - * @description the licence text on the publisher's site, the source's `license` - */ - url: string | null; - }; - /** - * TransformationLogEntry - * @description One step of the pipeline's transformations, in pipeline order. - */ - TransformationLogEntry: { - /** - * Description - * @description what the step does, as CommonMark - */ - description: string; - /** - * Transformation - * @description the step title, plain text - */ - transformation: string; - }; - /** - * Usage - * @description `api_access`/`python` are `None` for every source -- an absent - * member, not a stand-in object -- named in `gaps` alongside - * every other absent member. Built by `graph_api.usage.usage_document`, - * never constructed inline at a call site. - */ - Usage: { - /** @default null */ - api_access: components['schemas']['UsageEntry'] | null; - /** @default null */ - python: components['schemas']['UsageEntry'] | null; - }; - /** - * UsageEntry - * @description The shape a non-null `Usage.api_access`/`.python` entry takes. Both - * of its own members are always `None`, and no dcid produces an entry at - * all -- `graph_api.usage`'s own docstring is the record of why. - */ - UsageEntry: { - /** - * Code - * @default null - */ - code: string | null; - /** - * Documentation Url - * @default null - */ - documentation_url: string | null; - }; - /** - * VersionHistoryEntry - * @description One release of the pipeline's changelog, newest first. - */ - VersionHistoryEntry: { - /** - * Changes - * @description every change, group by group in order - */ - changes: components['schemas']['ChangeEntry'][]; - /** - * Date - * @description the release date, YYYY-MM-DD - */ - date: string; - /** - * Title - * @description the release title, plain text - */ - title: string; - }; - /** - * SourceDocumentation - * @description The full `GET /api/graph/documentation/{dcid}` response. - */ - source_documentation_response: { - /** - * Data Notes - * @description one caveat per entry, as CommonMark - */ - data_notes: string[]; - details: components['schemas']['SourceDetails']; - /** - * Document Errors - * @description each Markdown document the source carries but this door could not serve - */ - document_errors: components['schemas']['DocumentErrorEntry'][]; - /** - * Gaps - * @description every member path that is null or empty for this source, in spec order, e.g. 'details.citation', 'scope_and_units.countries' - */ - gaps: string[]; - license: components['schemas']['SourceLicense']; - methodology: components['schemas']['Methodology']; - scope_and_units: components['schemas']['ScopeAndUnits']; - /** Transformation Log */ - transformation_log: components['schemas']['TransformationLogEntry'][]; - usage: components['schemas']['Usage']; - /** Version History */ - version_history: components['schemas']['VersionHistoryEntry'][]; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export type operations = Record; diff --git a/src/lib/graph/test/wire.test.ts b/src/lib/graph/test/wire.test.ts deleted file mode 100644 index 1fb98b6..0000000 --- a/src/lib/graph/test/wire.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { test } from 'node:test'; - -import { renderGraphWire, SNAPSHOT, TYPES } from '../../../../scripts/graph-wire.mts'; - -test('schema.d.ts is what the committed Graph API schema snapshot generates', async () => { - const rendered = await renderGraphWire(await readFile(SNAPSHOT, 'utf8')); - - assert.equal( - await readFile(TYPES, 'utf8'), - rendered, - 'src/lib/graph/schema.d.ts differs from what the snapshot generates; run `pnpm graph:types` and review the diff', - ); -}); diff --git a/src/lib/graph/wire.ts b/src/lib/graph/wire.ts index c3d39fd..29aea28 100644 --- a/src/lib/graph/wire.ts +++ b/src/lib/graph/wire.ts @@ -1,25 +1,21 @@ /** - * Names for the Graph API bodies the site reads, taken from the types - * generated from the served schema (`schema.d.ts`, `pnpm graph:types`). + * Names for the Graph API bodies the site reads, from the client package's types generated from + * the served OpenAPI document. */ -import type { components } from './schema.d.ts'; +import type { NodesResponse } from '@developmentfinance/client/wire'; -type Schemas = components['schemas']; +export type { GraphNode } from '@developmentfinance/client'; +export type { + DatasetEntry, + DocumentErrorEntry, + NodesRequest, + SourceCatalogue, + SourceCatalogueEntry, + SourceDocumentation, + VersionHistoryEntry, +} from '@developmentfinance/client/wire'; -/** `POST /api/graph/nodes` request body. */ -export type NodesRequest = Schemas['nodes_request']; -/** One entry of the `POST /api/graph/nodes` response, which holds one per - * requested dcid, in order: a node's body, or a `not_held` entry. */ -export type NodeEntry = Schemas['nodes_response']['nodes'][number]; -/** `GET /api/graph/node/{dcid}` response body, discriminated by `kind`. */ -export type GraphNode = Schemas['node_response']; - -/** `GET /api/graph/sources` response body. */ -export type SourceCatalogue = Schemas['source_catalogue_response']; -export type SourceCatalogueEntry = Schemas['SourceCatalogueEntry']; -export type DatasetEntry = Schemas['DatasetEntry']; -/** `GET /api/graph/documentation/{dcid}` response body. */ -export type SourceDocumentation = Schemas['source_documentation_response']; -export type VersionHistoryEntry = Schemas['VersionHistoryEntry']; -export type DocumentErrorEntry = Schemas['DocumentErrorEntry']; +/** One entry of the `POST /api/graph/nodes` response, which holds one per requested dcid, in + * order: a node's body, or a `not_held` entry. */ +export type NodeEntry = NodesResponse['nodes'][number]; diff --git a/src/lib/search/catalogue.ts b/src/lib/search/catalogue.ts index a4eba01..bfb050b 100644 --- a/src/lib/search/catalogue.ts +++ b/src/lib/search/catalogue.ts @@ -10,57 +10,29 @@ * no examples. */ -/** One example sentence: a checked template filled with a place. */ -export type CatalogueExample = { - /** the sentence to show and run, e.g. "Health aid to Ethiopia" */ - text: string; - /** the template it fills, e.g. "Health aid to {place}" */ - template: string; - /** the StatVar the sentence resolves to */ - stat_var: string; - /** the place it is filled with */ - place: string; -}; +import type { + AidCounts, + CatalogueExampleSentence, + CatalogueResponse, + Counts, + PlaceCounts, +} from '@developmentfinance/client/wire'; -/** CRS counts. `years` is null when the build holds no CRS rows. */ -export type AidCounts = { - donors: number; - recipients: number; - years: { first: number; last: number } | null; -}; +/** One example sentence: a checked template filled with a place. */ +export type CatalogueExample = CatalogueExampleSentence; /** What DFO holds as a whole. `indicators` counts the StatVars search can * serve and `datasets` the source datasets behind them. */ -export type CatalogueCounts = { - publishers: number; - countries: number; - indicators: number; - datasets: number; - aid: AidCounts; -}; - -/** What DFO holds at one country. For aid, `donors` (the wire name) counts the - * providers to the country and `recipients` the recipients of its own aid. */ -export type PlaceCounts = { - publishers: number; - variables: number; - aid: AidCounts; -}; +export type CatalogueCounts = Counts; /** The whole catalogue (`GET /api/search/catalogue`). */ -export type SearchCatalogue = { - catalogue_version: string; - counts: CatalogueCounts; - examples: CatalogueExample[]; -}; +export type SearchCatalogue = Omit & { counts: CatalogueCounts }; /** One country's catalogue (`?place=`): its counts, and the examples with * data there, filled with its name. */ -export type PlaceCatalogue = { - catalogue_version: string; - counts: PlaceCounts; - examples: CatalogueExample[]; -}; +export type PlaceCatalogue = Omit & { counts: PlaceCounts }; + +export type { AidCounts, PlaceCounts }; /** The starting point's own question. Asked as a search, it shows the * starting point from the catalogue the page already holds, with no search diff --git a/src/lib/search/datasetEntries.ts b/src/lib/search/datasetEntries.ts index 9da61be..e408d1e 100644 --- a/src/lib/search/datasetEntries.ts +++ b/src/lib/search/datasetEntries.ts @@ -20,19 +20,20 @@ * candidate echo their reading's `reading_assumptions` list. */ -import type { - Assumption, - BaseIdentity, - DatasetCandidateIdentity, - DatasetCandidatesEntry, - DatasetIdentityEntry, - DatasetPlaceholderEntry, - DatasetResubmitEntry, - EntityGroup, - HydratedPart, - Operation, - Spec, -} from './types'; +import { + type Assumption, + type BaseIdentity, + type DatasetCandidateIdentity, + type DatasetCandidatesEntry, + type DatasetIdentityEntry, + type DatasetPlaceholderEntry, + type DatasetResubmitEntry, + type EntityGroup, + type HydratedPart, + knownPart, + type Operation, + type Spec, +} from './types.ts'; /** * How `buildDatasetEntries` deviates from "every part's own current spec": @@ -112,7 +113,9 @@ function readingAssumptions( part: HydratedPart, spec: Pick, ): { assumptions?: Assumption[] } { - const assumptions = part.part?.reading_assumptions?.[spec.spec_id]; + const known = knownPart(part.part); + const assumptions = + known?.status === 'candidates' ? known.reading_assumptions?.[spec.spec_id] : undefined; return assumptions ? { assumptions } : {}; } @@ -135,7 +138,8 @@ export function candidatePickEntry(part: HydratedPart, spec: Spec): DatasetIdent } function candidatesEntryFor(part: HydratedPart): DatasetCandidatesEntry { - const candidateSet = part.part?.candidates; + const known = knownPart(part.part); + const candidateSet = known?.status === 'candidates' ? known.candidates : undefined; if (!candidateSet) { throw new Error(`buildDatasetEntries: part ${part.index} has no candidate set`); } @@ -168,8 +172,10 @@ function candidatesEntryFor(part: HydratedPart): DatasetCandidatesEntry { function placeholderEntryFor(part: HydratedPart): DatasetPlaceholderEntry { const variable_text = part.variableText ?? ''; - if (part.part?.status === 'no_data') { - const reason = part.part.no_data?.reason; + const known = knownPart(part.part); + + if (known?.status === 'no_data') { + const reason = known.no_data.reason; return { entry_kind: 'placeholder', variable_text, @@ -178,7 +184,7 @@ function placeholderEntryFor(part: HydratedPart): DatasetPlaceholderEntry { }; } - const code = part.part?.unsupported?.code; + const code = known?.status === 'unsupported' ? known.unsupported.code : undefined; return { entry_kind: 'placeholder', variable_text, @@ -192,7 +198,8 @@ function placeholderEntryFor(part: HydratedPart): DatasetPlaceholderEntry { * no response has landed for it): a dataset_resubmit only ever posts over * parts that already have content. */ function entryForPart(part: HydratedPart): DatasetResubmitEntry { - switch (part.part?.status) { + // A part whose status this release does not know has no entry to post. + switch (knownPart(part.part)?.status) { case 'definite': return identityEntryFor(part); case 'candidates': @@ -248,17 +255,17 @@ function withOperations(entry: DatasetResubmitEntry, part: HydratedPart): Datase * no_data part assumed nothing because it answered nothing, so its edit * reads fresh. */ function withAssumptions(entry: DatasetResubmitEntry, part: HydratedPart): DatasetResubmitEntry { - const assumptions = part.part?.assumptions; + const known = knownPart(part.part); if ( entry.entry_kind !== 'identity' || entry.assumptions !== undefined || - part.part?.status !== 'definite' || - assumptions === undefined + known?.status !== 'definite' || + known.assumptions === undefined ) { return entry; } - return { ...entry, assumptions }; + return { ...entry, assumptions: known.assumptions }; } /** diff --git a/src/lib/search/datasetsFetch.ts b/src/lib/search/datasetsFetch.ts index eba415b..806d354 100644 --- a/src/lib/search/datasetsFetch.ts +++ b/src/lib/search/datasetsFetch.ts @@ -23,6 +23,8 @@ * the browser client and the server routes share it. */ +import type { ProblemDetails } from '@developmentfinance/client/wire'; + import { apisFetch, type Fetch } from '../apisAuth.ts'; export type { Fetch }; @@ -69,7 +71,7 @@ const isRetryableRefusal = async (res: Response): Promise => { .json() .catch(() => null); - return (body as { retryable?: unknown } | null)?.retryable === true; + return (body as Partial | null)?.retryable === true; }; /** The wait a `Retry-After` header names, in seconds or as a date, in ms; diff --git a/src/lib/search/definitionMembers.ts b/src/lib/search/definitionMembers.ts index 64846c3..35392a7 100644 --- a/src/lib/search/definitionMembers.ts +++ b/src/lib/search/definitionMembers.ts @@ -15,6 +15,7 @@ */ import { fetchDefinitionMembers } from './searchClient.ts'; +import type { Spec } from './types.ts'; /** A served StatVar as the panels read it: its bare dcid and label. */ export type MemberRef = { dcid: string; label: string | null }; @@ -22,11 +23,10 @@ export type MemberRef = { dcid: string; label: string | null }; export type MembersRead = { ok: true; members: MemberRef[] } | { ok: false }; /** The part of a page Spec the reader keys on and sends. */ -export type MemberSpec = { - spec_id: string; - definition?: unknown; - shape?: { member_digest?: string | null } | null; -}; +export type MemberSpec = Pick & + Partial> & { + shape?: Partial> | null; + }; type FetchMembers = typeof fetchDefinitionMembers; diff --git a/src/lib/search/departures.ts b/src/lib/search/departures.ts index 4d7e898..856084c 100644 --- a/src/lib/search/departures.ts +++ b/src/lib/search/departures.ts @@ -3,8 +3,8 @@ * each offers. No React. */ import { offersStudioCallout } from './resultNotes.ts'; -import { tokenPart } from './sentencePlan.ts'; -import type { Departure, Part, SentenceSegment, SentenceToken } from './types.ts'; +import { tokenPart, tokenSegment } from './sentencePlan.ts'; +import type { Departure, NoDataPart, Part, SentenceSegment, SentenceToken } from './types.ts'; export type SentenceBlock = { segment: Exclude; @@ -25,12 +25,14 @@ export function sentenceBlocks(plan: readonly SentenceToken[] | null | undefined const blocks: SentenceBlock[] = []; for (const token of plan ?? []) { - if (token.segment === 'departure') continue; + const segment = tokenSegment(token); + + if (segment === 'departure') continue; const last = blocks.at(-1); - if (last?.segment === token.segment) last.tokens.push(token); - else blocks.push({ segment: token.segment, tokens: [token] }); + if (last?.segment === segment) last.tokens.push(token); + else blocks.push({ segment, tokens: [token] }); } return blocks @@ -111,7 +113,7 @@ const isVisibleSlotOf = ( return ( token.kind === 'slot' && - (token.segment === 'heading' || token.segment === 'byline') && + ['heading', 'byline'].includes(tokenSegment(token)) && token.text.trim() !== '' && (owner === partIndex || owner === null) ); @@ -196,7 +198,7 @@ export type AnswerDeparture = { * `no_data` and `restatement` decide whether its callout offers Studio. */ export function answerDepartures( parts: readonly (Pick & - Partial>)[], + Partial & Pick>)[], plan: readonly SentenceToken[] | null | undefined, ): AnswerDeparture[] { const several = parts.length > 1; diff --git a/src/lib/search/frozenObservations.ts b/src/lib/search/frozenObservations.ts index 50ccb09..358d010 100644 --- a/src/lib/search/frozenObservations.ts +++ b/src/lib/search/frozenObservations.ts @@ -13,6 +13,8 @@ * Pure, so `node --test` imports it directly. */ +import type { Part, Spec } from './types.ts'; + /** The ref spec_id a replayed definite part was bound to, with the spec_id the * replay returned for it. `refSpecId` is null when the response does not carry * one part per ref, so no ref owns the part. Null for a part that is not @@ -20,7 +22,7 @@ export type SnapshotBinding = { replayedSpecId: string; refSpecId: string | null } | null; /** The slice of a replayed part this module reads; a `Resolution` part fits. */ -type ReplayedPart = { status: string; spec?: { spec_id: string } }; +type ReplayedPart = Pick & { spec?: Pick }; /** Binds each definite replayed part to its share ref by position. When the * response does not carry one part per ref, position proves nothing: every diff --git a/src/lib/search/measureMenu.ts b/src/lib/search/measureMenu.ts index 667f1d1..f65c5a8 100644 --- a/src/lib/search/measureMenu.ts +++ b/src/lib/search/measureMenu.ts @@ -12,7 +12,6 @@ import type { DatasetIdentityEntry, GraphRef, MeasureAxisValue, - OpenConstraintAxisFact, RelatedMeasure, Slot, Spec, @@ -27,12 +26,10 @@ import { export { openAxisFactLabel }; -export type MeasureMenuSpec = { - spec_id: string; - slots: Slot[]; - alternative_measures?: SpecAlternativeMeasure[] | null; - open_axis_facts?: OpenConstraintAxisFact[] | null; -}; +export type MeasureMenuSpec = Pick< + Spec, + 'spec_id' | 'slots' | 'alternative_measures' | 'open_axis_facts' | 'download' +>; /** One group of the measure menu: a slot the measure token states, with * its options. */ diff --git a/src/lib/search/operationPoll.ts b/src/lib/search/operationPoll.ts index 0757618..52e7c57 100644 --- a/src/lib/search/operationPoll.ts +++ b/src/lib/search/operationPoll.ts @@ -16,27 +16,21 @@ * `fetchImpl`, a thin wrapper that resolves the envelope's own relative * `poll` path against wherever it needs to go (see searchClient.ts's * `mintDataset`, which polls through the site's own same-origin proxy, - * and searchShare.ts's `capture`, which polls the Datasets API directly — + * and snapshot.ts's `captureSnapshot`, which polls the Datasets API directly, since * a done poll needs no principal header, unlike the `pin` call next to it). */ -import { datasetsFetch } from './datasetsFetch.ts'; - -export type OperationStatus = 'pending' | 'running' | 'done' | 'failed'; +import type { + Manifest, + OperationEnvelope, + OperationProgress, + OperationResource, + ProblemDetails, +} from '@developmentfinance/client/wire'; -export type OperationProgress = { - rows_written: number; - estimate_rows: number; -}; +import { datasetsFetch } from './datasetsFetch.ts'; -/** The wire's OperationEnvelope, verbatim field names. */ -export type OperationEnvelope = { - operation_id: string; - status: OperationStatus; - poll?: string | null; - retry_after_ms?: number | null; - progress?: OperationProgress | null; -}; +export type { OperationEnvelope, OperationProgress }; export type PollResponse = { ok: boolean; status: number; json: () => Promise }; export type PollFetch = (url: string, signal?: AbortSignal) => Promise; @@ -145,7 +139,7 @@ export type OperationPollError = Error & { }; const pollError = (body: unknown, status: number): OperationPollError => { - const record = (body ?? {}) as { detail?: string; title?: string; code?: string }; + const record = (body ?? {}) as Partial; const message = record.detail ?? record.title ?? `operations poll failed with status ${status}`; const err = new Error(message) as OperationPollError; @@ -156,15 +150,15 @@ const pollError = (body: unknown, status: number): OperationPollError => { /** * Polls one operation to a terminal state, starting from the envelope a - * `202` (or a prior poll) returned. Resolves with the done manifest, or - * throws an `OperationPollError` on `failed`, on any non-2xx poll + * `202` (or a prior poll) returned. Resolves with the done manifest (null + * when the done poll carries none), or throws an `OperationPollError` on `failed`, on any non-2xx poll * response, or once `deadlineMs` has elapsed without reaching a terminal * state (an Error whose `code` is OPERATION_DEADLINE). */ export async function pollOperation( envelope: OperationEnvelope, options: PollOperationOptions = {}, -): Promise { +): Promise { const { fetchImpl = defaultFetch, onProgress, @@ -239,7 +233,7 @@ export async function pollOperation( throw pollError(body, res.status); } - const resource = body as { operation?: OperationEnvelope; manifest?: unknown } | null; + const resource = body as Partial | null; const operation = resource?.operation; if (!operation) { @@ -249,7 +243,9 @@ export async function pollOperation( } if (operation.status === 'done') { - return resource?.manifest ?? null; + // The resource types the manifest as an open object; a done poll + // returns the manifest a synchronous mint does. + return (resource?.manifest ?? null) as Manifest | null; } current = operation; diff --git a/src/lib/search/operations.ts b/src/lib/search/operations.ts index 74d3dbb..8ce95f1 100644 --- a/src/lib/search/operations.ts +++ b/src/lib/search/operations.ts @@ -1,8 +1,8 @@ /** The label of the button that hands a part's operations to Studio. */ -import type { OperationVerb } from './types.ts'; +import { hasKey, type Known, type OperationVerb } from './types.ts'; -const BUTTON_LABELS: Record = { +const BUTTON_LABELS: Record, string> = { rank: 'Rank in Studio', aggregate: 'Add up in Studio', growth: 'Work out the change in Studio', @@ -12,7 +12,8 @@ const BUTTON_LABELS: Record = { report: 'Ask Studio for a report', }; -/** The Studio button, named for the verb of the operation it hands over. */ +/** The Studio button, named for the verb of the operation it hands over; a verb this release + * does not know gets the plain label. */ export function operationButtonLabel(verb: OperationVerb): string { - return BUTTON_LABELS[verb]; + return hasKey(BUTTON_LABELS, verb) ? BUTTON_LABELS[verb] : 'Open in Studio'; } diff --git a/src/lib/search/pickerState.ts b/src/lib/search/pickerState.ts index 9fe3773..3a70ae2 100644 --- a/src/lib/search/pickerState.ts +++ b/src/lib/search/pickerState.ts @@ -15,7 +15,7 @@ import type { Coverage, Warning } from './types.ts'; * view.ts's splitCurrentPick can bound its generic to it, rather than * taking unconstrained options and forcing a cast here. */ export interface OptionLike { - value?: { ref?: { ref?: string; label?: string } }; + value?: { ref?: { ref?: string; label?: string } | null }; dcid?: string; label?: string; coverage?: Coverage | null; diff --git a/src/lib/search/promote.ts b/src/lib/search/promote.ts index aaa5a4b..e780af9 100644 --- a/src/lib/search/promote.ts +++ b/src/lib/search/promote.ts @@ -6,6 +6,7 @@ import type { IdentityBinding, IdentityFacetAxis, IdentityFacetRefBinding, + IdentityFacetBinding, IdentityFacets, Spec, } from './types'; @@ -21,41 +22,33 @@ import type { // `{kind, value:{ref:{ref,label}}}` — these adapters bridge the two without // asking the editors to know about the identity wire shape at all. +/** A slot's binding in the engine's resolved shape. */ +type ResolvedBinding = Spec['slots'][number]['binding']; + /** The bound dcid a resolved binding carries, or null when it binds nothing - * (unbound, or a shape this adapter doesn't recognise). */ -function resolvedBindingDcid(binding: unknown): string | null { - const b = binding as { kind?: string; value?: { ref?: { ref?: string } } } | null | undefined; - if (!b || b.kind === 'unbound') return null; - return b.value?.ref?.ref ?? null; + * (unbound, or a set, which names no single dcid). */ +function resolvedBindingDcid(binding: ResolvedBinding | null | undefined): string | null { + return binding?.kind === 'value' ? (binding.value.ref?.ref ?? null) : null; } -/** One dcid for value, every member's for set. None for unbound/absent or - * an unrecognised shape. */ -function resolvedBindingDcids(binding: unknown): string[] { - const b = binding as - | { - kind?: string; - value?: { ref?: { ref?: string } }; - values?: { ref?: { ref?: string } }[]; - } - | null - | undefined; - - if (!b) return []; - - if (b.kind === 'set') { - return (b.values ?? []) - .map((value) => value?.ref?.ref) - .filter((dcid): dcid is string => !!dcid); +/** One dcid for value, every member's for set. None for unbound or absent. */ +function resolvedBindingDcids(binding: ResolvedBinding | null | undefined): string[] { + switch (binding?.kind) { + case 'set': + return binding.values.flatMap((value) => value.ref?.ref ?? []); + case 'value': + return binding.value.ref?.ref ? [binding.value.ref.ref] : []; + default: + return []; } - - return b.value?.ref?.ref ? [b.value.ref.ref] : []; } /** Convert a resolved what/how slot binding to its identity-slot form. Every * shape-declared slot is dense (always present, `unbound` if nothing is * bound), so — unlike facets/entities — there is no "drop" case here. */ -export function toIdentitySlotBinding(binding: unknown): IdentityBinding { +export function toIdentitySlotBinding( + binding: ResolvedBinding | null | undefined, +): IdentityBinding { const dcid = resolvedBindingDcid(binding); return dcid ? { state: 'value', ref: dcid } : { state: 'unbound' }; } @@ -63,7 +56,9 @@ export function toIdentitySlotBinding(binding: unknown): IdentityBinding { /** Convert a resolved binding to a facet binding, or null when it should be * dropped from the sparse `facets` map entirely (the facet union has no * `unbound` state — an unconstrained axis is simply absent). */ -function toIdentityFacetBinding(binding: unknown): IdentityFacetRefBinding | null { +function toIdentityFacetBinding( + binding: ResolvedBinding | null | undefined, +): IdentityFacetRefBinding | null { const dcid = resolvedBindingDcid(binding); return dcid ? { state: 'value', ref: dcid } : null; } @@ -114,7 +109,7 @@ export function withEntityEdit( export function withFacetEdit( identity: BaseIdentity, axis: A, - binding: IdentityFacets[A] | null, + binding: IdentityFacetBinding[A] | null, ): BaseIdentity { const facets: IdentityFacets = { ...identity.facets }; @@ -138,7 +133,7 @@ export function withFacetEdit( export function withResolvedSlotEdit( spec: Pick, slotIndex: number, - binding: unknown, + binding: ResolvedBinding, entityRole: EntityRoleKey = 'observationAbout', ): BaseIdentity { const identity = spec.identity as BaseIdentity; diff --git a/src/lib/search/readings.ts b/src/lib/search/readings.ts index 836c249..f73bf91 100644 --- a/src/lib/search/readings.ts +++ b/src/lib/search/readings.ts @@ -6,7 +6,7 @@ */ import { refsStatedBesides, sentenceBlocks } from './departures.ts'; -import { planSentenceText, tokenPart } from './sentencePlan.ts'; +import { planSentenceText, tokenPart, tokenSegment } from './sentencePlan.ts'; import type { CandidateBasis, Part, SentenceToken } from './types'; /** What one reading card shows: its heading and byline as plain text, the @@ -58,7 +58,7 @@ export function readingOf(spec: { sentence_plan?: SentenceToken[] | null }): Rea * assumptions clause are left out, since the answer block states them. */ export function readingsSummary(plan: readonly SentenceToken[] | null | undefined): string { return (plan ?? []) - .filter((token) => token.segment === 'heading' || token.segment === 'byline') + .filter((token) => ['heading', 'byline'].includes(tokenSegment(token))) .map((token) => token.text) .join('') .trim(); diff --git a/src/lib/search/refineMenu.ts b/src/lib/search/refineMenu.ts index 1cbc1cb..19fd45f 100644 --- a/src/lib/search/refineMenu.ts +++ b/src/lib/search/refineMenu.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- reads specs, slots and bindings as `any`, dereferencing only the members it names and tolerating any that is missing */ /** * What Refine offers: the bindings of the answer's definite parts that no * visible token states (SentenceToken.states), each with something to pick: diff --git a/src/lib/search/registerDefinitions.ts b/src/lib/search/registerDefinitions.ts index 0d45e7b..b8aafe5 100644 --- a/src/lib/search/registerDefinitions.ts +++ b/src/lib/search/registerDefinitions.ts @@ -8,6 +8,8 @@ * `node --test` imports it directly. */ +import type { ProblemDetails } from '@developmentfinance/client/wire'; + import { apisFetch } from '../apisAuth.ts'; const REGISTER_TIMEOUT_MS = 20_000; @@ -39,7 +41,7 @@ export const registerDefinitions = async ({ }); if (!res.ok) { - const body: { code?: string } | null = await res.json().catch(() => null); + const body: Partial | null = await res.json().catch(() => null); throw new Error(body?.code ?? `register ${res.status}`); } diff --git a/src/lib/search/resultNotes.ts b/src/lib/search/resultNotes.ts index 6a8de66..ce9e42a 100644 --- a/src/lib/search/resultNotes.ts +++ b/src/lib/search/resultNotes.ts @@ -108,6 +108,11 @@ export function downloadNote( : null; } +/** A warning param that holds a list of strings, or [] for one that holds a number or a + * string. */ +const listParam = (value: number | string | string[] | undefined): string[] => + Array.isArray(value) ? value : []; + /** The "Data not available" line for a part's failed UN reads, read from * the `variables` and `variable_labels` params of its * `un_system_unavailable` warnings (dfo-apis un_system.py @@ -134,9 +139,9 @@ export function unSystemFailedReads( // Each variable's label, empty where the warning holds none. const labels = new Map( unavailable.flatMap((warning) => { - const names = warning.params?.variable_labels ?? []; + const names = listParam(warning.params?.variable_labels); - return (warning.params?.variables ?? []).map( + return listParam(warning.params?.variables).map( (dcid, at) => [dcid, names[at] ?? ''] as const, ); }), diff --git a/src/lib/search/searchClient.ts b/src/lib/search/searchClient.ts index 834e337..6c53cb8 100644 --- a/src/lib/search/searchClient.ts +++ b/src/lib/search/searchClient.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- turns unvalidated response bodies and stream events into the page's shapes, so bodies are `any` */ /** * Browser-side search client for the /prototype gut-check page. * @@ -10,11 +10,34 @@ * so no CORS setup is needed. Override NEXT_PUBLIC_SEARCH_API_BASE only to hit a * remote engine origin directly. * - * MIRROR: A deliberately-shallow type layer of the engine contract. See the - * fuller types in `lib/search/types.ts` (source of truth: the dfo-apis Search - * API schema). Keep this layer in sync as the contract evolves. + * The SSE event types below are hand-written, since the OpenAPI document does + * not describe the stream. The response, slot key and Datasets door types come + * from `@developmentfinance/client/wire`, generated from that document, through + * `lib/search/types.ts` or directly. */ +import type { + ArtifactEntryModel, + CoverageDimension, + DatasetResubmitInput, + EnrichmentOptionModel, + FormatOptionModel, + Manifest, + MembersEnvelope, + MintOptions, + MintRequest, + OptionsEnvelope, + OptionsRequest, + OutputOptionsModel, + ProblemDetails, + ProblemIssue, + RawTextInput, + ServedMember, + SlotKey, + SlotOption, + Spec as WireSpec, +} from '@developmentfinance/client/wire'; + import { datasetsFetch } from './datasetsFetch.ts'; import { pollOperation } from './operationPoll.ts'; import type { OperationProgress } from './operationPoll'; @@ -25,44 +48,40 @@ import { type VersionLinkDocument, type VersionLinkOutcome, } from './versionLink.ts'; -import type { - Assumption, - DatasetResubmitEntry, - LateDepartures, - EntityGroup, - EntityRoleKey, - Frame, - Operation, - Part, - PlaceOptionsScope, - Resolution, - ResolveStatus, - RoleAxis, - SentenceToken, - SpecObservations, - Warning, -} from './types'; +import { + type Assumption, + type Coverage, + type EntityGroup, + type EntityRoleKey, + type FacetOptionsEntry, + type Frame, + isKnownPart, + type Known, + type LateDepartures, + type Operation, + type Part, + type PartSpec, + type PlaceOptionsScope, + type Resolution, + type ResolveResponse, + type ResolveStatus, + type RoleAxis, + type SentenceToken, + type Slot, + type SlotOptions, + type Spec, + type SpecIdentity, + type SpecObservations, + type Warning, +} from './types.ts'; export const SEARCH_BASE = process.env.NEXT_PUBLIC_SEARCH_API_BASE ?? ''; -// Deliberately shallow types: the gut-check renders whatever the engine sends, -// so only the fields the page dereferences are typed; the rest stays `any`. -export type SlotKey = { - axis: string; - property: { ref: string; label: string } | null; - label: string; -}; +export type { SlotKey }; // The stream's sentence plans carry the same token shape as the response's. export type { SentenceToken }; -export type SlotOptionsData = { - key: SlotKey; - options: any[]; - truncated: boolean; - source?: 'observed' | 'seed'; -}; - export type SearchEvent = | { kind: 'interpretation'; @@ -94,7 +113,7 @@ export type SearchEvent = label: string; slot_keys: SlotKey[]; sentence_plan: SentenceToken[]; - slot_options: SlotOptionsData[]; + slot_options: SlotOptions[]; refine_supported: boolean; } | { @@ -108,7 +127,7 @@ export type SearchEvent = variable_index: number; spec_id: string; sentence_plan: SentenceToken[]; - slots: any[]; + slots: Slot[]; } | { kind: 'step'; @@ -117,18 +136,22 @@ export type SearchEvent = ran: boolean; ms?: number | null; } - | { kind: 'response'; response: any } - | { kind: 'slot_options'; spec_id: string; key: SlotKey; options: any[]; truncated: boolean } + | { kind: 'response'; response: ResolveResponse } | { + kind: 'slot_options'; + spec_id: string; + key: SlotKey; + options: SlotOption[]; + truncated: boolean; + } + | ({ /** Coverage-derived facet values (unit / measurementMethod / * provenance), streamed AFTER the response event — the response's * own facet_options array arrives empty on SSE. */ kind: 'facet_options'; spec_id: string; - axis: string; - options: any[]; truncated?: boolean; - } + } & Omit) | { kind: 'entity_options'; spec_id: string; @@ -136,7 +159,7 @@ export type SearchEvent = // their candidate list. entity_ref?: string | null; role: RoleAxis; - options: any[]; + options: SlotOption[]; truncated: boolean; } | { @@ -159,10 +182,10 @@ export type SearchEvent = | { kind: 'observations'; spec_id: string; - /** SpecObservations (see lib/search/types.ts): same shape the buffered - * path embeds at Spec.observations, deferred to a trailing event on SSE - * so the response isn't blocked on the observation fetch. */ - observations: any; + /** The same shape the buffered path embeds at Spec.observations, + * deferred to a trailing event on SSE so the response does not wait + * on the observation fetch. */ + observations: SpecObservations; } | { kind: 'done'; @@ -204,15 +227,9 @@ export function keyRef(key: SlotKey): string { return `${key.axis}:${key.property?.ref ?? ''}`; } -export type SearchInput = - | { kind: 'raw_text'; query: string } - | { - kind: 'dataset_resubmit'; - /** 1 to 6, in response part order; see types.ts's DatasetResubmitEntry */ - entries: DatasetResubmitEntry[]; - /** echo-only; the engine must not re-parse it */ - raw_query?: string; - }; +/** A resolve request's input: a question in words, or 1 to 6 entries posted back in response + * part order. */ +export type SearchInput = RawTextInput | DatasetResubmitInput; export function buildRequest(input: SearchInput, includeCoverage: boolean) { return { @@ -245,8 +262,6 @@ export function buildRequest(input: SearchInput, includeCoverage: boolean) { // envelope enters: `resolved` is spread flat onto the Spec. // .no_data.gate needs no flattening. It's already a scalar. -type WireSpec = { spec_id: string; definition?: any; resolved?: any } & Record; - /** Why `validateResolvedStatVars` rejected a value, or that it passed. A * typed result rather than a bare boolean, so the one caller (below) can * report what specifically didn't match instead of re-deriving it from the @@ -279,9 +294,11 @@ function validateResolvedStatVars(value: unknown): StatVarsValidation { return { valid: true }; } -/** Flatten one wire Spec to the page's Spec. */ -export function flattenSpec(spec: WireSpec | null | undefined): any { - if (!spec) return spec ?? null; +/** Flatten one wire Spec to the page's Spec, or flag it unreadable. */ +export function flattenSpec(spec: WireSpec): PartSpec; +export function flattenSpec(spec: WireSpec | null | undefined): PartSpec | null; +export function flattenSpec(spec: WireSpec | null | undefined): PartSpec | null { + if (!spec) return null; if (!spec.resolved) { // Every deployment sends the definition/resolved split (contract @@ -295,21 +312,22 @@ export function flattenSpec(spec: WireSpec | null | undefined): any { 'split (contract v1 requires both) — got', spec, ); + // The body broke the contract the type states, so it holds none of + // the resolved members. return { ...spec, stat_vars_unreadable: true }; } - const { spec_id, definition, resolved, ...rest } = spec; - const flat = { - ...rest, + const { spec_id, definition, resolved } = spec; + const flat: Spec = { ...resolved, spec_id, // The wire's `trace` is the page's `resolution`. - resolution: resolved.trace ?? resolved.resolution ?? {}, + resolution: resolved.trace, // The hashed identity, echoed verbatim on a dataset_resubmit entry. - identity: definition?.identity ?? null, + identity: definition.identity, // The canonical definition — the Datasets API mints unregistered specs // from it (the "sidecar"). - definition: definition ?? null, + definition, }; // Contract boundary check, deliberately not a coercion. A `stat_vars` @@ -330,7 +348,8 @@ export function flattenSpec(spec: WireSpec | null | undefined): any { `{member_count, member_count_exact, served_count, data_date_range} shape (ResolvedStatVars) — ${validation.reason} — got`, resolved.stat_vars, ); - flat.stat_vars_unreadable = true; + + return { ...flat, stat_vars_unreadable: true }; } } @@ -341,12 +360,13 @@ export function flattenSpec(spec: WireSpec | null | undefined): any { * spec that carried the definition/resolved split. `identity` and * `resolution` are the two members `flattenSpec` adds; every other member * came from `resolved`, `trace` included. */ -export function unflattenSpec(spec: any): { +export function unflattenSpec(spec: Spec): { spec_id: string; definition: any; resolved: Record; } { - const { spec_id, definition, ...resolved } = spec; + const { spec_id, definition, ...flat } = spec; + const resolved: Record = flat; delete resolved.identity; delete resolved.resolution; @@ -456,7 +476,8 @@ export function mergeLateWarnings(response: Resolution, warnings: Warning[]): Re if (bySpec.size === 0) return response; const parts = response.parts.map((part) => { - const late = part.status === 'definite' && part.spec ? bySpec.get(part.spec.spec_id) : null; + const late = + isKnownPart(part) && part.status === 'definite' ? bySpec.get(part.spec.spec_id) : null; if (!late?.length) return part; @@ -510,7 +531,12 @@ export function applyLateDepartures(response: Resolution, late: LateDepartures[] const parts = response.parts.map((part, index) => { const entry = byPart.get(index); - if (!entry || part.status !== 'definite' || part.spec?.spec_id !== entry.spec_id) { + if ( + !entry || + !isKnownPart(part) || + part.status !== 'definite' || + part.spec.spec_id !== entry.spec_id + ) { return part; } changed = true; @@ -540,20 +566,23 @@ export function applyDoneEvent( * field rides through untouched. */ function flattenPart(part: any): any { if (!part || typeof part !== 'object') return part; - // Part.restatement and Part.operations are required; the wire's - // WirePart fields are optional. Default them here so a normalized Part - // never carries `undefined` for either despite its type. - const restatement = part.restatement ?? null; - const operations = part.operations ?? []; + // The page Part's restatement, operations, warnings and departures are + // required, on a part of any status; the wire's are optional. Default + // them here so a normalised Part never carries `undefined` for one. + const defaults = { + restatement: part.restatement ?? null, + operations: part.operations ?? [], + warnings: part.warnings ?? [], + departures: part.departures ?? [], + }; if (part.spec) { - return { ...part, restatement, operations, spec: flattenSpec(part.spec) }; + return { ...part, ...defaults, spec: flattenSpec(part.spec) }; } if (part.candidates) { return { ...part, - restatement, - operations, + ...defaults, candidates: { ...part.candidates, specs: (part.candidates.specs ?? []).map(flattenSpec), @@ -563,8 +592,7 @@ function flattenPart(part: any): any { if (part.no_data) { return { ...part, - restatement, - operations, + ...defaults, no_data: { ...part.no_data, nearest_real: Array.isArray(part.no_data.nearest_real) @@ -575,7 +603,7 @@ function flattenPart(part: any): any { }, }; } - return { ...part, restatement, operations }; + return { ...part, ...defaults }; } /** @@ -594,7 +622,7 @@ function flattenPart(part: any): any { * place is an error, never a guess. resolveStream turns the throw into the * run's `error` event. */ -export function normalizeResponse(body: any): any { +export function normalizeResponse(body: any): ResolveResponse { if (body?.kind === 'resolution' && Array.isArray(body.parts)) { return { ...body, parts: body.parts.map(flattenPart) }; } @@ -647,28 +675,22 @@ function detailText(body: any, status: number): string { /** One `issues[]` entry on a refusal: what the engine objected to, and — * where it can — what would have been valid instead. */ -export type ApiIssue = { - code?: string | null; - axis?: string | null; - pointer?: string | null; - detail?: string | null; - alternatives?: { ref: string; label?: string | null }[] | null; -}; +export type ApiIssue = ProblemIssue; export type ApiError = Error & { /** the response's own HTTP status, so a console log can name it without * re-deriving it from the message text. */ status?: number; - code?: string | null; - pointer?: string | null; + code?: ProblemDetails['code'] | null; + pointer?: ProblemDetails['pointer']; /** `over_ceiling` only: which ceiling was hit, and the two numbers that * let the message say so plainly instead of in prose. `candidate_window` * counts the measures a broad family matched, so its `count` is the * candidates the read listed rather than the result's size. */ - ceilingKind?: 'member_cardinality' | 'rows' | 'bytes' | 'candidate_window' | string | null; - count?: number | null; - cap?: number | null; - issues?: ApiIssue[] | null; + ceilingKind?: ProblemDetails['ceiling_kind']; + count?: ProblemDetails['count']; + cap?: ProblemDetails['cap']; + issues?: ProblemDetails['issues']; }; /** A refusal body's own `code` and RFC 9457 `pointer`, top-level fields @@ -699,15 +721,15 @@ function apiError(body: any, status: number): ApiError { // --------------------------------------------------------------------------- export type EntityOptionsRequest = { - identity: any; + identity: SpecIdentity; // The wire's full role vocabulary (EntityRoleKey): a directional // entity_options call names 'origin'/'destination' explicitly; an // omitted role takes the door's own 'observationAbout' default below. role?: EntityRoleKey; - scope: 'peers' | 'children'; + scope: PlaceOptionsScope; }; -export type EntityOption = { dcid: string; label: string; coverage?: any }; +export type EntityOption = { dcid: string; label: string; coverage?: Coverage | null }; /** `options.items` from a POST /api/search/options body, or the wire * SlotOption[] a `place_options` SSE event carries (same per-item shape — @@ -715,14 +737,12 @@ export type EntityOption = { dcid: string; label: string; coverage?: any }; * `items` wrapper) — reshaped to the flat client `EntityOption` PlaceEditor * renders. An item missing a dcid or label is dropped rather than shown * half-filled. */ -export function placeOptionValues(items: any[]): EntityOption[] { - return (items ?? []) - .filter((item) => item?.value?.ref?.ref && item?.value?.ref?.label) - .map((item) => ({ - dcid: item.value.ref.ref, - label: item.value.ref.label, - coverage: item.coverage ?? undefined, - })); +export function placeOptionValues(items: readonly SlotOption[]): EntityOption[] { + return items.flatMap(({ value, coverage }) => + value.ref?.ref && value.ref.label + ? [{ dcid: value.ref.ref, label: value.ref.label, coverage: coverage ?? undefined }] + : [], + ); } export async function entityOptions( @@ -738,7 +758,7 @@ export async function entityOptions( for: { role: req.role ?? 'observationAbout', scope: req.scope }, // Per-option data footprint (observation counts / years) for the rows. include_coverage: true, - }), + } satisfies OptionsRequest), signal, }); const body = await res.json().catch(() => null); @@ -762,7 +782,7 @@ export async function entityOptions( // sidecar). // --------------------------------------------------------------------------- -export type DatasetFormat = 'csv' | 'xlsx' | 'parquet'; +export type DatasetFormat = Exclude, undefined>; // The three formats the Datasets API and a Studio card's export route render, // in the order the download sheet offers them. Which of them a given dataset @@ -775,13 +795,7 @@ export const DATASET_FORMATS: readonly { id: DatasetFormat; label: string }[] = { id: 'parquet', label: 'Parquet' }, ]; -export type DatasetArtifact = { - url: string; - media_type?: string; - bytes?: number; - role?: string; - filename?: string; -} & Record; +export type DatasetArtifact = ArtifactEntryModel; /** The address a browser or fetch uses for an artifact's `url`, which is * root-relative (`/api/datasets/{spec}/{cv}/files/{digest}`). */ @@ -812,7 +826,7 @@ export async function mintDataset( * runs as a background operation (202 only — never called on a * synchronous 200). */ onProgress?: (progress: OperationProgress) => void, -): Promise<{ artifacts: DatasetArtifact[]; manifest: any }> { +): Promise<{ artifacts: DatasetArtifact[]; manifest: Manifest | null }> { const res = await datasetsFetch(`${SEARCH_BASE}/api/datasets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -828,7 +842,7 @@ export async function mintDataset( bundle: req.bundle ?? true, ...(req.columns?.length ? { columns: req.columns } : {}), }, - }), + } satisfies MintRequest), signal, }); const body = await res.json().catch(() => null); @@ -838,22 +852,11 @@ export async function mintDataset( if (res.status === 202) { const manifest = await pollOperation(body.operation, { onProgress, signal }); - return { artifacts: ((manifest as any)?.artifacts ?? []) as DatasetArtifact[], manifest }; + return { artifacts: manifest?.artifacts ?? [], manifest }; } - return { artifacts: (body?.artifacts ?? []) as DatasetArtifact[], manifest: body }; -} - -/** The Datasets API's capability document (GET /api/datasets/capabilities): - * deployment-mutable switches — which formats are live, and which - * enrichment columns (ISO codes etc.) the reshape step can populate. */ -export async function fetchDatasetCapabilities(signal?: AbortSignal): Promise { - try { - const res = await datasetsFetch(`${SEARCH_BASE}/api/datasets/capabilities`, { signal }); + const manifest: Manifest | null = body; - return res.ok ? await res.json() : null; - } catch { - return null; - } + return { artifacts: manifest?.artifacts ?? [], manifest }; } /** One country's search catalogue (GET /api/search/catalogue?place=): its @@ -877,61 +880,21 @@ export async function fetchSearchCatalogue( } } -/** One output option the engine advertises for a dataset. `state` is the - * authority: only `available` may be offered; `partial` is offerable but - * incomplete (see `coverage`/`missing`), anything else is off. */ -export type DatasetOptionState = 'available' | 'partial' | string; - -export type DatasetEnrichment = { - /** the column the extra field joins onto, e.g. observationAbout */ - column: string; - /** what it adds, e.g. iso3 / iso2 / label */ - field: string; - /** the value to post in `options.columns`, e.g. observationAbout_iso3 */ - token: string; - state: DatasetOptionState; - reason?: string | null; - coverage?: { entities?: number; covered?: number } | null; - missing?: { ref: string; label?: string | null }[] | null; -}; +/** One output option's state. Only `available` may be offered; `partial` is offerable but + * incomplete (see an enrichment's `coverage` and `missing`); anything else is off. */ +export type DatasetOptionState = FormatOptionModel['state']; -export type DatasetOutputOptions = { - capability_version?: string; - columns?: { name: string; kind?: string; droppable?: boolean; distinct?: number }[]; - formats?: { name: string; state: DatasetOptionState }[]; - bundle?: { state: DatasetOptionState } | null; - enrichments?: DatasetEnrichment[]; - /** the enrichment tokens a long-layout download writes with no `columns` - * option, e.g. financingRecipient_label. The extras list leaves them - * out, since they are always present. Absent from an API that predates - * it. */ - long_layout_defaults?: string[]; - /** the roles (frame column names, e.g. financingRecipient) whose total - * rows a long-layout CSV or XLSX keeps out of its main table. A bundle - * writes them to a second CSV, an XLSX to a second sheet, a bare CSV - * leaves them out. Parquet keeps every row. Empty when no role sets rows - * apart, and absent from an API that predates it. */ - long_layout_totals?: string[]; - pivots?: { index: string; columns: string; state: DatasetOptionState }[]; - groupby?: unknown; -}; +export type DatasetEnrichment = EnrichmentOptionModel; -/** One coverage dimension of a dataset: a frame column (or `years`) and its - * distinct count. `axis` names the flow direction a role column serves - * (financingProvider → origin) and is absent for anything else. */ -export type DatasetCoverageDimension = { - label: string; - count?: number; - axis?: 'origin' | 'destination' | null; -}; +export type DatasetOutputOptions = OutputOptionsModel; -/** The parts of an options document, or of a mint's manifest, that the - * download sheet reads: the output options and the coverage dimensions - * that say which role each column serves. */ -export type DatasetOptionsDocument = { - output_options?: DatasetOutputOptions | null; - coverage?: { dimensions?: DatasetCoverageDimension[] } | null; -}; +/** One coverage dimension of a dataset: a frame column (or `years`) and its distinct count. + * `axis` names the flow direction a role column serves (financingProvider → origin). */ +export type DatasetCoverageDimension = CoverageDimension; + +/** The parts of an options document, or of a mint's manifest, that the download sheet reads: + * the output options and the coverage whose dimensions say which role each column serves. */ +export type DatasetOptionsDocument = Pick; /** * What one dataset says it can do (`/api/datasets/{spec_id}/options`): @@ -972,27 +935,15 @@ export async function fetchDatasetOptions( } } -/** One StatVar a definition serves, as the members door lists it (dfo-apis - * datasets_api/schema.py ServedMember): `ref` is `dcid:`-prefixed, `label` - * null when the graph holds none, and `constraints` maps each constraint - * property to its value, null when unbound. */ -export type DefinitionMember = { - ref: string; - label: string | null; - roles: string[]; - constraints: Record; -}; +/** One StatVar a definition serves, as the members door lists it: `ref` is `dcid:`-prefixed, + * `label` null when the graph holds none, and `constraints` maps each constraint property to its + * value, null when unbound. */ +export type DefinitionMember = ServedMember; -/** What the members door (`POST /api/datasets/{spec_id}/members`) answers - * (MembersEnvelope): every StatVar the definition serves, sorted by `ref`, - * the served count, and the size and digest of the family the members came - * from, each null when the door cannot state it. */ -export type DefinitionMembers = { - members: DefinitionMember[]; - served_count: number; - family_member_count: number | null; - family_member_digest: string | null; -}; +/** What the members door (`POST /api/datasets/{spec_id}/members`) answers: every StatVar the + * definition serves, sorted by `ref`, the served count, and the size and digest of the family the + * members came from, each null when the door cannot state it. */ +export type DefinitionMembers = MembersEnvelope; /** * The StatVars one definition serves, resolved by the Datasets members door @@ -1061,7 +1012,7 @@ function readOrStall( * is not one the page can render (see normalizeResponse). False on the * error. */ function emitResponse(onEvent: (ev: SearchEvent) => void, body: unknown, frame?: object): boolean { - let response: any; + let response: ResolveResponse; try { response = normalizeResponse(body); } catch (error) { @@ -1184,7 +1135,7 @@ export async function resolveStream( * Rejects on an `error` event before the response, on a stream that ends * without one, and with AbortError on an abort. */ -export function resolveSuggestion(query: string, signal: AbortSignal): Promise { +export function resolveSuggestion(query: string, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { resolveStream( buildRequest({ kind: 'raw_text', query }, true), @@ -1204,7 +1155,7 @@ export function resolveSuggestion(query: string, signal: AbortSignal): Promise; + suggest(query: string): Promise; /** Aborts the live stream unless it asked `query`; null aborts it * whatever it asked. */ keepOnly(query: string | null): void; @@ -1220,7 +1171,11 @@ export type SuggestionSlot = { * retries. */ export function createSuggestionSlot(): SuggestionSlot { - let live: { query: string; controller: AbortController; response: Promise } | null = null; + let live: { + query: string; + controller: AbortController; + response: Promise; + } | null = null; const keepOnly = (query: string | null) => { if (live && live.query !== query) { diff --git a/src/lib/search/searchEvents.ts b/src/lib/search/searchEvents.ts index 15d56ec..39396e2 100644 --- a/src/lib/search/searchEvents.ts +++ b/src/lib/search/searchEvents.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- reads the flat page Spec, typed shallowly like the rest of the engine-contract layer */ /** * The props of the search usage events (docs/analytics.md): `search.query`, * `search.empty`, `search.edit` and `search.studio_handoff` all describe one @@ -25,16 +24,19 @@ */ import { fitsProps } from '../events/limits.ts'; -import type { - DeclinedReason, - HydratedPart, - IdentityBinding, - OperationVerb, - Resolution, - ResolveResponse, - Spec, - SpecIdentity, +import { + type DeclinedReason, + hasDefiniteSpec, + type HydratedPart, + type IdentityBinding, + knownPart, + type OperationVerb, + type Resolution, + type ResolveResponse, + type Spec, + type SpecIdentity, } from './types.ts'; +import { statVarsUnreadable } from './view.ts'; /** How many entity terms a refused part keeps. */ export const EVENT_SAMPLE = 3; @@ -187,13 +189,13 @@ const askedFor = ( measure: identity.shape, filters, entities: Object.fromEntries( - Object.entries(identity.entities).map(([role, members]) => { + Object.entries(identity.entities ?? {}).map(([role, members]) => { const group = groups?.[role]; return [ role, { - group: group ? { dcid: group.ref, label: group.label } : null, + group: group ? { dcid: group.ref ?? null, label: group.label } : null, sample: sample(members), count: members.length, }, @@ -227,9 +229,7 @@ export function searchEventProps({ const operations = [ ...new Set(parts.flatMap((part) => (part.part?.operations ?? []).map((op) => op.verb))), ]; - const definite = parts - .filter((part) => part.part?.status === 'definite' && part.hydratedSpec) - .map((part) => part.hydratedSpec as any); + const definite = parts.filter(hasDefiniteSpec).map((part) => part.hydratedSpec); const resolve = (rung: Rung) => definite.map((spec) => { const sources: string[] = (spec.resolution?.resolved_sources ?? []).map( @@ -239,8 +239,11 @@ export function searchEventProps({ const asked = askedFor(spec.identity, spec.entity_groups); const counts = { specId: spec.spec_id, - // The served members once settled, else the family's count. - statVarCount: spec.stat_vars?.served_count ?? spec.stat_vars?.member_count ?? 0, + // The served members once settled, else the family's count; + // none for a spec the site could not read. + statVarCount: statVarsUnreadable(spec) + ? 0 + : (spec.stat_vars?.served_count ?? spec.stat_vars?.member_count ?? 0), entityCount: spec.resolution?.resolved_entities?.length ?? 0, sourceCount: sources.length, }; @@ -270,14 +273,19 @@ export function searchEventProps({ }); const refused = parts .filter((part) => part.part && part.part.status !== 'definite') - .map((part) => ({ - variableText: part.variableText && clip(part.variableText, MAX_TERM), - status: part.part!.status, - reason: part.part!.no_data?.reason ?? part.part!.unsupported?.code ?? null, - entityTerms: sample(part.part!.no_data?.entity_terms ?? []).map((term) => - clip(term, MAX_TERM), - ), - })); + .map((part) => { + const known = knownPart(part.part); + const noData = known?.status === 'no_data' ? known.no_data : undefined; + + return { + variableText: part.variableText && clip(part.variableText, MAX_TERM), + status: part.part!.status, + reason: + noData?.reason ?? + (known?.status === 'unsupported' ? known.unsupported.code : null), + entityTerms: sample(noData?.entity_terms ?? []).map((term) => clip(term, MAX_TERM)), + }; + }); const runProps = (rung: Rung): Partial => { if (!run) { diff --git a/src/lib/search/searchShare.ts b/src/lib/search/searchShare.ts index c5475cf..688b09a 100644 --- a/src/lib/search/searchShare.ts +++ b/src/lib/search/searchShare.ts @@ -144,9 +144,9 @@ export const getSearchShare = async (id: string): Promise => variableText: ref.variableText, specId: ref.specId, identity: ref.identity, - entityGroups: ref.entityGroups as Record | null, - operations: ref.operations as Operation[], - assumptions: ref.assumptions as Assumption[] | null, + entityGroups: ref.entityGroups, + operations: ref.operations, + assumptions: ref.assumptions, contentVersion: ref.contentVersion, artifactDigest: ref.artifactDigest, })), diff --git a/src/lib/search/searchShareRequest.ts b/src/lib/search/searchShareRequest.ts index 22d0e45..7b2c38c 100644 --- a/src/lib/search/searchShareRequest.ts +++ b/src/lib/search/searchShareRequest.ts @@ -1,11 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- request bodies are validated field by field below */ -import type { - Assumption, - AssumptionKind, - EntityGroup, - EntityRoleKey, - HydratedPart, - Operation, +import { + type Assumption, + type AssumptionKind, + type DefiniteHydratedPart, + type EntityGroup, + type EntityRoleKey, + hasKey, + knownPart, + type Operation, } from './types.ts'; /** @@ -86,11 +88,13 @@ const isEntityGroups = (value: any): value is Record => ([role, group]) => ENTITY_ROLE_KEYS.includes(role) && isEntityGroup(group), ); -const ASSUMPTION_KINDS: string[] = [ - 'declared_default', - 'open_role', - 'place_scope', -] satisfies AssumptionKind[]; +// Keyed by every kind the wire names, so a kind Search adds fails the type +// check until this list takes it. +const ASSUMPTION_KINDS: Record = { + declared_default: true, + open_role: true, + place_scope: true, +}; // Search's own bounds on an echoed `Assumption`: a text of 1 to 200 code // points, at most one ref of at most 1024 characters, a binding ref of at @@ -107,14 +111,18 @@ const isAssumption = (value: any): value is Assumption => Object.keys(value).every((member) => ['kind', 'binding_ref', 'refs', 'text'].includes(member), ) && - ASSUMPTION_KINDS.includes(value.kind) && + typeof value.kind === 'string' && + hasKey(ASSUMPTION_KINDS, value.kind) && typeof value.binding_ref === 'string' && value.binding_ref.length <= ASSUMPTION_BINDING_REF_MAX_LENGTH && - Array.isArray(value.refs) && - value.refs.length <= ASSUMPTION_MAX_REFS && - value.refs.every( - (ref: unknown) => typeof ref === 'string' && ref.length <= ASSUMPTION_REF_MAX_LENGTH, - ) && + // `refs` is optional on the wire, so an item may leave it out. + (value.refs === undefined || + (Array.isArray(value.refs) && + value.refs.length <= ASSUMPTION_MAX_REFS && + value.refs.every( + (ref: unknown) => + typeof ref === 'string' && ref.length <= ASSUMPTION_REF_MAX_LENGTH, + ))) && typeof value.text === 'string' && value.text.length > 0 && // A text within the bound spans at most two UTF-16 units per code point. @@ -145,8 +153,8 @@ export const isSearchSharePart = (value: any): value is SearchSharePart => /** The share part a definite part on screen posts: its Spec's identity, with * the Spec's groups beside it, and the part's operations and assumptions. */ -export const searchSharePart = (part: HydratedPart): Required => { - const spec = part.hydratedSpec as any; +export const searchSharePart = (part: DefiniteHydratedPart): Required => { + const spec = part.hydratedSpec; return { partIndex: part.index, @@ -155,7 +163,7 @@ export const searchSharePart = (part: HydratedPart): Required = identity: spec.identity, entityGroups: spec.entity_groups ?? null, operations: part.part?.operations ?? [], - assumptions: part.part?.assumptions ?? null, + assumptions: knownPart(part.part)?.assumptions ?? null, definition: spec.definition, }; }; diff --git a/src/lib/search/sentencePlan.ts b/src/lib/search/sentencePlan.ts index af4ab5c..16d0b69 100644 --- a/src/lib/search/sentencePlan.ts +++ b/src/lib/search/sentencePlan.ts @@ -6,7 +6,12 @@ * layout. Pure; no React. */ -import type { SentenceToken } from './types'; +import type { SentenceSegment, SentenceToken } from './types'; + +/** The segment a token sits in: its own, or the heading, the wire's default + * for a token without one. A segment the site does not know stays as it is, + * so no reader draws it. */ +export const tokenSegment = (token: SentenceToken): SentenceSegment => token.segment ?? 'heading'; /** The part `token` belongs to, in a response of `partCount` parts. With * one part (or none yet), every token is that part's, whatever indexes @@ -52,10 +57,10 @@ export function earlySentenceTokens( const segmentText = ( plan: readonly SentenceToken[] | null | undefined, - segment: SentenceToken['segment'], + segment: SentenceSegment, ): string => (plan ?? []) - .filter((token) => token.segment === segment) + .filter((token) => tokenSegment(token) === segment) .map((token) => token.text) .join('') .trim(); diff --git a/src/lib/search/shareDownload.ts b/src/lib/search/shareDownload.ts index 87d24b0..d09534b 100644 --- a/src/lib/search/shareDownload.ts +++ b/src/lib/search/shareDownload.ts @@ -30,9 +30,11 @@ * `node --test` imports it directly. */ +import type { OperationResource, ProblemDetails } from '@developmentfinance/client/wire'; + import { datasetsFetch } from './datasetsFetch.ts'; import { DEFAULT_DOWNLOAD_CHOICES } from './downloadOptions.ts'; -import { OPERATION_DEADLINE, pollOperation, type OperationEnvelope } from './operationPoll.ts'; +import { OPERATION_DEADLINE, pollOperation } from './operationPoll.ts'; import { artifactHref, DATASET_FORMATS } from './searchClient.ts'; import type { SearchShare } from './searchShare'; import { MINT_DEFAULT_RENDERING, renderingArtifact, type FileRendering } from './snapshot.ts'; @@ -338,7 +340,7 @@ const busy = (retryAfter: string | null = null): DownloadAnswer => export type PinServed = (served: { manifest: VersionLinkDocument; specId: string; - digest: string | null; + digest: string; }) => Promise; type LiveDownload = { @@ -418,7 +420,7 @@ export const serveLiveDownload = async ({ } if (res.status === 202) { - const operation = (body as { operation?: OperationEnvelope } | null)?.operation; + const operation = (body as Partial | null)?.operation; if (operation && OPERATION_SHAPE.test(operation.operation_id)) { return html @@ -481,7 +483,7 @@ const awaitOperation = async ({ now, log, }: { - operation: OperationEnvelope; + operation: OperationResource['operation']; target: Extract; base: string; pin: PinServed; @@ -498,7 +500,7 @@ const awaitOperation = async ({ let manifest: VersionLinkDocument | null; try { - manifest = (await pollOperation( + manifest = await pollOperation( { ...operation, poll: pollUrl }, { fetchImpl: (_path, attempt) => @@ -511,7 +513,7 @@ const awaitOperation = async ({ ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), }, - )) as VersionLinkDocument | null; + ); } catch (error) { if (signal?.aborted) { return CANCELLED; @@ -563,7 +565,7 @@ const doorRefusal = ({ body: unknown; log: (message: string, fields: Record) => void; }): DownloadAnswer => { - const problem = body as { code?: string; detail?: string } | null; + const problem = body as Partial | null; const detail = problem?.detail ? ` ${problem.detail}` : ''; if (res.status === 404 || res.status === 410) { diff --git a/src/lib/search/sharedEdit.ts b/src/lib/search/sharedEdit.ts new file mode 100644 index 0000000..117b652 --- /dev/null +++ b/src/lib/search/sharedEdit.ts @@ -0,0 +1,80 @@ +/** + * A hoisted qualifier's edit, fanned out to every definite part that carries the axis: which + * parts it touches, and how it rewrites each touched part's identity. `useSearchRun.editShared` + * posts the result as one `dataset_resubmit`. + * + * Pure, so `node --test` imports it directly. + */ + +import { withEntityEdit, withResolvedSlotEdit } from './promote.ts'; +import type { + BaseIdentity, + EntityRoleKey, + HydratedPart, + IdentityFacetBinding, + IdentityUnitRequest, + PartSpec, + RoleAxis, + Slot, +} from './types.ts'; +import { entityOptionsEntry, type EntityOptionsBySpec } from './view.ts'; +import type { WindowApplyValue } from './windowState.ts'; + +/** A hoisted where/when/source/origin/unit/method qualifier's edit, fanned + * out to every definite part by `editShared`: one axis, applied everywhere + * it hoisted. A unit request applies to every part that converts + * (`sharedUnitEdit`); a method binding to every part that binds a method, + * the parts a stated method token speaks for. */ +export type SharedEdit = + | { axis: 'where'; binding: Slot['binding']; entityRole: EntityRoleKey } + | { axis: 'source'; binding: Slot['binding'] } + | { axis: 'when'; window: WindowApplyValue } + | { axis: 'entity'; role: RoleAxis; dcid: string } + | { axis: 'unit'; request: IdentityUnitRequest } + | { axis: 'method'; binding: IdentityFacetBinding['measurementMethod'] | null }; + +/** A shared edit that rewrites a slot or an entity role. */ +export type SlotSharedEdit = Extract; + +/** The indexes of the definite parts `edit` touches. `origin` (the provider) is not a slot: it + * lives on the spec's entities, so an entity edit touches every part with entity options for + * its role. Any other edit touches every part with a slot on its axis; a spec flagged + * unreadable that carries no slots has none to edit. */ +export function sharedEditTouched( + parts: readonly HydratedPart[], + edit: SlotSharedEdit, + entityOptionsBySpec: EntityOptionsBySpec, +): number[] { + return parts.flatMap((part) => { + const spec = part.hydratedSpec; + + if (part.part?.status !== 'definite' || !spec) return []; + if (edit.axis === 'entity') { + return entityOptionsEntry(spec, entityOptionsBySpec, edit.role) ? [part.index] : []; + } + return spec.slots?.some((slot) => slot.key.axis === edit.axis) ? [part.index] : []; + }); +} + +/** `identity` with `edit` applied, for a part `sharedEditTouched` returned. */ +export function withSharedEdit( + identity: BaseIdentity, + spec: PartSpec, + edit: SlotSharedEdit, +): BaseIdentity { + if (edit.axis === 'entity') { + return withEntityEdit(identity, edit.role, [edit.dcid]); + } + if (edit.axis === 'when') { + return { ...identity, requested_window: edit.window }; + } + + const slots = spec.slots ?? []; + + return withResolvedSlotEdit( + { identity: spec.identity ?? null, slots }, + slots.findIndex((slot) => slot.key.axis === edit.axis), + edit.binding, + edit.axis === 'where' ? edit.entityRole : 'observationAbout', + ); +} diff --git a/src/lib/search/snapshot.ts b/src/lib/search/snapshot.ts index a2d35e8..892e4af 100644 --- a/src/lib/search/snapshot.ts +++ b/src/lib/search/snapshot.ts @@ -13,51 +13,59 @@ * `/api/download-pin` pins a downloaded version through it. */ +import type { + ArtifactEntryModel, + Manifest, + MintOptions, + PinRequest, + ProblemDetails, +} from '@developmentfinance/client/wire'; + import { datasetsFetch } from './datasetsFetch.ts'; -import { pollOperation, type OperationEnvelope } from './operationPoll.ts'; +import { pollOperation } from './operationPoll.ts'; import { SPEC_SHAPE, VERSION_SHAPE } from './versionLink.ts'; /** The fields of a Datasets manifest this module reads. */ -export type SnapshotManifest = { - content_version?: string; - definition?: { spec_id?: string } | null; - artifacts?: { artifact_digest?: string; tier?: string; rendering?: object }[]; -}; +export type SnapshotManifest = Pick; /** The rendering a file was made with, as a manifest lists it * (`MintOptions` dumped without its unset fields). */ -export type FileRendering = { format: string; bundle: boolean }; +export type FileRendering = Required>; /** What a mint without options renders (dfo-apis' `MintOptions` defaults), a * CSV bundle. A snapshot capture mints and pins this rendering. */ export const MINT_DEFAULT_RENDERING: FileRendering = { format: 'csv', bundle: true }; -// A rendering that carries any of these is reshaped or enriched, so it is a -// different file from the plain one with the same format and bundle. -const RESHAPES = ['columns', 'pivot', 'groupby']; +// A rendering that sets any of these (every MintOptions member beside `format` and `bundle`) is +// reshaped, enriched or cut, so it is a different file from the plain one with the same format and +// bundle. A null or false member is unset. +const RESHAPES = [ + 'columns', + 'pivot', + 'groupby', + 'layout', + 'without_party_totals', +] as const satisfies readonly (keyof MintOptions)[]; /** - * The listed file made with exactly `format` and `bundle` and no reshape, or + * The listed file made with exactly `format` and `bundle` and no reshape or cut, or * undefined. A version manifest lists every rendering stored for the * version, so the first bundle it lists can be an xlsx or column-enriched * one. A statistics artifact carries no format and never matches. */ export const renderingArtifact = < - TArtifact extends { artifact_digest?: string; rendering?: object }, + TArtifact extends Pick, >( artifacts: readonly TArtifact[] | undefined, { format, bundle }: FileRendering, ): TArtifact | undefined => - artifacts?.find((artifact) => { - const rendering = (artifact.rendering ?? {}) as Record; - - return ( - !!artifact.artifact_digest && + artifacts?.find( + ({ artifact_digest, rendering }) => + !!artifact_digest && rendering.format === format && rendering.bundle === bundle && - RESHAPES.every((key) => !(key in rendering)) - ); - }); + RESHAPES.every((key) => !rendering[key]), + ); /** The pin failed or cannot be attempted, so the snapshot would expire with * the working tier. POST /api/share answers this code as its `detail`. */ @@ -125,7 +133,7 @@ export const pinArtifact = async ({ }: PinOptions): Promise => { const pinned = await poster(base, fetchImpl)( `/api/datasets/${specId}/${contentVersion}/pin`, - { schema_version: 1, artifact_digest: artifactDigest }, + { schema_version: 1, artifact_digest: artifactDigest } satisfies PinRequest, { headers: { 'x-dfo-principal': principal }, timeoutMs: DOOR_TIMEOUT_MS }, ).catch((error: unknown) => ({ ok: false, status: 0, body: null, error })); @@ -195,7 +203,7 @@ export const captureSnapshot = async ({ // poll needs no principal. const manifest = ( minted.status === 202 - ? await pollOperation(minted.body.operation as OperationEnvelope, { + ? await pollOperation(minted.body.operation, { fetchImpl: (url) => fetchImpl(`${base}${url}`, { cache: 'no-store', @@ -204,7 +212,7 @@ export const captureSnapshot = async ({ deadlineMs: pollDeadlineMs, }) : minted.body - ) as (SnapshotManifest & { code?: string }) | null; + ) as (Partial & Partial>) | null; if (typeof manifest?.content_version !== 'string') { throw new Error(manifest?.code ?? `mint ${minted.status}`); diff --git a/src/lib/search/studioHandoff.ts b/src/lib/search/studioHandoff.ts index d0a7ae4..3605d70 100644 --- a/src/lib/search/studioHandoff.ts +++ b/src/lib/search/studioHandoff.ts @@ -6,10 +6,11 @@ * all describe the data the user sees. */ -import type { Seed } from '../studio/wire.ts'; +import type { Seed, SeedOperation } from '../studio/wire.ts'; import { unflattenSpec } from './searchClient.ts'; -import type { HydratedPart, Operation } from './types.ts'; +import { type HydratedPart, knownPart, type Operation } from './types.ts'; +import { statVarsUnreadable } from './view.ts'; /** Whether the answer on screen may differ from the query's own reading: a * part was edited, or the answer was opened from a share link, which stores @@ -48,27 +49,54 @@ export function handoffQuestion({ export function handoffSeeds(parts: HydratedPart[]): Seed[] { const seeds = new Map(); - for (const { part } of parts) { - if (part?.status !== 'definite' || !part.spec) { + for (const hydrated of parts) { + const part = knownPart(hydrated.part); + + if (part?.status !== 'definite') { continue; } - const block = unflattenSpec(part.spec); - - if (!block.definition || block.resolved.stat_vars_unreadable) { + if (statVarsUnreadable(part.spec) || !part.spec.definition) { continue; } + const block = unflattenSpec(part.spec); + const seed: Seed = seeds.get(block.spec_id) ?? { spec: block, operations: [] }; const known = new Set(seed.operations.map(operationKey)); - seed.operations.push(...part.operations.filter((op) => !known.has(operationKey(op)))); + seed.operations.push( + ...part.operations.filter(isSeedOperation).filter((op) => !known.has(operationKey(op))), + ); seeds.set(block.spec_id, seed); } return [...seeds.values()]; } +const SEED_VERBS: Record = { + rank: true, + aggregate: true, + growth: true, + share_of: true, + explain: true, + forecast: true, + report: true, +}; + +const SEED_AXES: Record, true> = { + place: true, + origin: true, + destination: true, + category: true, + year: true, +}; + +/** True for an operation Studio reads: one whose verb and axis Studio's wire names. A verb or + * axis the Search API adds before Studio knows it stays out of the handoff. */ +const isSeedOperation = (op: Operation): op is Operation & SeedOperation => + Object.hasOwn(SEED_VERBS, op.verb) && (op.over == null || Object.hasOwn(SEED_AXES, op.over)); + const operationKey = ({ verb, phrase, over, target_year }: Operation): string => JSON.stringify([verb, phrase, over ?? null, target_year ?? null]); diff --git a/src/lib/search/test/datasetEntries.test.ts b/src/lib/search/test/datasetEntries.test.ts index 205c783..71f4792 100644 --- a/src/lib/search/test/datasetEntries.test.ts +++ b/src/lib/search/test/datasetEntries.test.ts @@ -3,31 +3,50 @@ import test from 'node:test'; import { buildDatasetEntries, candidatePickEntry, sharedRefEntry } from '../datasetEntries.ts'; import { withEntityEdit } from '../promote.ts'; -import { normalizeResponse, restorePlaceholderParts } from '../searchClient.ts'; -import type { - Assumption, - DatasetCandidatesEntry, - DatasetIdentityEntry, - DatasetParsedEntry, - HydratedPart, - Part, - Resolution, +import { restorePlaceholderParts } from '../searchClient.ts'; +import { + type Assumption, + type DatasetCandidatesEntry, + type DatasetIdentityEntry, + type DatasetParsedEntry, + type HydratedPart, + knownPart, + type Part, + type Resolution, + type Spec, } from '../types.ts'; +import { readable, resolution } from './fixtures/resolution.ts'; import { fixtures, type FixtureName } from './fixtures/index.ts'; /** The fixture's normalized parts as a settled run holds them. */ function hydratedParts(name: FixtureName): HydratedPart[] { - const parts: Part[] = normalizeResponse(fixtures[name]).parts; - - return parts.map((part, index) => ({ - index, - variableText: part.variable_text ?? null, - part, - hydratedSpec: part.status === 'definite' ? (part.spec ?? null) : null, - observationsPending: false, - phase: 'done', - editError: null, - })); + const parts: Part[] = resolution(fixtures[name]).parts; + + return parts.map((part, index) => { + const known = knownPart(part); + + return { + index, + variableText: part.variable_text ?? null, + part, + hydratedSpec: known?.status === 'definite' ? known.spec : null, + observationsPending: false, + phase: 'done', + editError: null, + }; + }); +} + +/** The assumptions a known part carries; none for an unknown one. */ +const assumptionsOf = (part: HydratedPart): Assumption[] | undefined => + knownPart(part.part)?.assumptions; + +/** The specs of a candidates part. */ +function candidateSpecs(part: HydratedPart): Spec[] { + const known = knownPart(part.part); + + assert.ok(known?.status === 'candidates'); + return known.candidates.specs.map(readable); } /** A `parsed` entry for `part`: its restatement with `dcid` pinned on the @@ -64,8 +83,8 @@ test('a pick on part 1 of two posts the parsed entry there and keeps part 0 as i test("the pick's answer lands at part 1 and part 0 keeps its dataset", () => { const parts = hydratedParts('two-part-ambiguous'); - const [pick] = normalizeResponse(fixtures['definite-rank-operation']).parts; - const answer: Resolution = normalizeResponse(fixtures['two-part-ambiguous']); + const [pick] = resolution(fixtures['definite-rank-operation']).parts; + const answer: Resolution = resolution(fixtures['two-part-ambiguous']); // The door's answer: part 0 re-resolved from its identity entry, part 1 // resolved from the pinned parsed entry. const posted: Resolution = { ...answer, parts: [answer.parts[0], pick] }; @@ -76,8 +95,12 @@ test("the pick's answer lands at part 1 and part 0 keeps its dataset", () => { 2, ); + const first = knownPart(merged.parts[0]); + const base = knownPart(parts[0].part); + assert.equal(merged.parts.length, 2); - assert.equal(merged.parts[0].spec?.spec_id, parts[0].part?.spec?.spec_id); + assert.ok(first?.status === 'definite' && base?.status === 'definite'); + assert.equal(first.spec.spec_id, base.spec.spec_id); assert.equal(merged.parts[1], pick); assert.equal(merged.status, 'definite'); }); @@ -115,7 +138,7 @@ test("buildDatasetEntries posts a parsed entry's part unchanged, its own operati test("buildDatasetEntries echoes a part's assumptions on its identity entry, an edit's included", () => { const parts = hydratedParts('broad-truncated'); - const assumptions = parts[0].part?.assumptions; + const assumptions = assumptionsOf(parts[0]); const [own] = buildDatasetEntries(parts) as DatasetIdentityEntry[]; const override: DatasetIdentityEntry = { ...own, assumptions: undefined }; const [edited] = buildDatasetEntries(parts, { index: 0, entry: override }); @@ -133,7 +156,7 @@ test("an edit that carries its own echo keeps it over the part's list", () => { entry: { ...own, assumptions: [] }, }) as DatasetIdentityEntry[]; - assert.ok(parts[0].part?.assumptions?.length); + assert.ok(assumptionsOf(parts[0])?.length); assert.deepEqual(replayed.assumptions, []); }); @@ -193,7 +216,7 @@ function candidatesWithReadings( readings: (specIds: string[]) => Record | undefined, ): HydratedPart { const [part] = hydratedParts('multi-candidate'); - const specIds = part.part!.candidates!.specs.map((spec) => spec.spec_id); + const specIds = candidateSpecs(part).map((spec) => spec.spec_id); return { ...part, @@ -212,7 +235,7 @@ const twoReadings = ([first, second]: string[]) => ({ test("a pick echoes the picked reading's own assumptions, not the readings' shared list", () => { const part = candidatesWithReadings(twoReadings); - const [first, second] = part.part!.candidates!.specs; + const [first, second] = candidateSpecs(part); for (const [spec, expected] of [ [first, [everyProvider, everyPlace]], @@ -242,7 +265,7 @@ test('a reading the response gives no list for posts no echo, on a pick or a can candidatesWithReadings(() => undefined), candidatesWithReadings(([first]) => ({ [first]: [] })), ]) { - const specs = part.part!.candidates!.specs; + const specs = candidateSpecs(part); const pick = candidatePickEntry(part, specs[1]); const [posted] = buildDatasetEntries([part], { index: 0, entry: pick }); const [entry] = buildDatasetEntries([part]) as DatasetCandidatesEntry[]; @@ -270,16 +293,20 @@ test("an identity entry and each candidate post their own Spec's groups beside t const g7 = { origin: { ref: null, label: 'G7 countries' } }; const [definite] = hydratedParts('definite-single'); const [candidates] = hydratedParts('multi-candidate'); - const [first, ...others] = candidates.part!.candidates!.specs; + const candidatesPart = knownPart(candidates.part); + + assert.ok(candidatesPart?.status === 'candidates'); + + const [first, ...others] = candidatesPart.candidates.specs; const parts: HydratedPart[] = [ { ...definite, hydratedSpec: { ...definite.hydratedSpec!, entity_groups: eu } }, { ...candidates, index: 1, part: { - ...candidates.part!, + ...candidatesPart, candidates: { - ...candidates.part!.candidates!, + ...candidatesPart.candidates, specs: [{ ...first, entity_groups: g7 }, ...others], }, }, @@ -304,7 +331,7 @@ test("an identity entry and each candidate post their own Spec's groups beside t }); assert.ok(rewritten.entry_kind === 'identity'); - assert.deepEqual(rewritten.identity.entities.origin, ['country/DEU']); + assert.deepEqual(rewritten.identity.entities?.origin, ['country/DEU']); assert.deepEqual(rewritten.entity_groups, eu); }); @@ -347,7 +374,7 @@ test('a stored share part replays its groups beside the identity, and no member test('a stored share part echoes its assumptions, [] included, and a share stored before them posts none', () => { const [part] = hydratedParts('broad-truncated'); const identity = part.hydratedSpec!.identity; - const assumptions = part.part!.assumptions!; + const assumptions = assumptionsOf(part)!; const ref = { variableText: 'aid', identity, entityGroups: null, operations: [] }; assert.ok(assumptions.length); diff --git a/src/lib/search/test/definitionMembers.test.ts b/src/lib/search/test/definitionMembers.test.ts index 4b370e9..2dc73fa 100644 --- a/src/lib/search/test/definitionMembers.test.ts +++ b/src/lib/search/test/definitionMembers.test.ts @@ -5,6 +5,8 @@ import { definitionMembersReader } from '../definitionMembers.ts'; import type { DefinitionMembers } from '../searchClient.ts'; const envelope: DefinitionMembers = { + schema_version: 1, + spec_id: 'spec_1', members: [ { ref: 'dcid:Count_Person_Female', label: 'Female population', roles: [], constraints: {} }, { ref: 'dcid:Count_Person_Male', label: null, roles: [], constraints: {} }, @@ -20,7 +22,7 @@ test('definitionMembersReader reads each spec once and hands back bare dcids', a calls += 1; return envelope; }); - const spec = { spec_id: 'spec_1', definition: {}, shape: { member_digest: 'sha256:ab' } }; + const spec = { spec_id: 'spec_1', definition: null, shape: { member_digest: 'sha256:ab' } }; assert.deepEqual(await read(spec), { ok: true, diff --git a/src/lib/search/test/departures.test.ts b/src/lib/search/test/departures.test.ts index 3c94392..879ec6e 100644 --- a/src/lib/search/test/departures.test.ts +++ b/src/lib/search/test/departures.test.ts @@ -10,8 +10,8 @@ import { sentenceBlocks, statingTokenIndex, } from '../departures.ts'; -import { normalizeResponse } from '../searchClient.ts'; import { planCoverageText, planSentenceText } from '../sentencePlan.ts'; +import { resolution } from './fixtures/resolution.ts'; import { fixtures } from './fixtures/index.ts'; import { noDataResponse, wireSpec } from './fixtures/no-data.ts'; import { servedAnswer } from './fixtures/served-answer.ts'; @@ -66,6 +66,24 @@ test('sentenceBlocks joins heading runs a departure separated -- a no_data part assert.deepEqual(sentenceBlocks(null), []); }); +test('sentenceBlocks reads a token with no segment as heading, and keeps one it does not know out of the heading', () => { + assert.deepEqual( + blockTexts([ + { kind: 'text', text: 'Aid' }, + { kind: 'text', text: ' to Kenya.', segment: 'heading' }, + { kind: 'text', text: ' ' }, + { kind: 'text', text: 'Figures are provisional.', segment: 'aside' }, + { kind: 'text', text: ' ', segment: 'byline' }, + { kind: 'text', text: 'OECD.', segment: 'byline' }, + ]), + [ + ['heading', 'Aid to Kenya.'], + ['aside', 'Figures are provisional.'], + ['byline', 'OECD.'], + ], + ); +}); + // --------------------------------------------------------------------------- // The assumptions clause and the mixed-answer opener // --------------------------------------------------------------------------- @@ -302,7 +320,7 @@ test('answerDepartures hands an operation to Studio unless the part draws the St const action = (part: Parameters[0][number]) => answerDepartures([{ ...part, departures: [ranking] }], crs045.sentence_plan)[0].action; const noDataPart = (nearFields: Record) => - normalizeResponse(noDataResponse('variable_not_resolved', null, nearFields)).parts[0]; + resolution(noDataResponse('variable_not_resolved', null, nearFields)).parts[0]; // The generic no_data callout already offers Studio. assert.equal(action(noDataPart({})), null); @@ -375,6 +393,16 @@ test('departureAction opens the token that states a ref it does not carry as its plan[statingTokenIndex(plan, 1, 0, 'how:crsTypeOfFlow')].text, 'gross ODA disbursements', ); + + // A heading token without a segment, the wire default, states it alike. + const unsegmented = plan.map((token): SentenceToken => + token.segment === 'heading' ? { ...token, segment: undefined } : token, + ); + + assert.equal( + statingTokenIndex(unsegmented, 1, 0, 'how:crsTypeOfFlow'), + statingTokenIndex(plan, 1, 0, 'how:crsTypeOfFlow'), + ); }); test('departureAction opens the source token for a source departure, through states or on a wire without them', () => { diff --git a/src/lib/search/test/downloadOptions.test.ts b/src/lib/search/test/downloadOptions.test.ts index d9be8f7..f9b2ece 100644 --- a/src/lib/search/test/downloadOptions.test.ts +++ b/src/lib/search/test/downloadOptions.test.ts @@ -18,27 +18,39 @@ const enrichment = ( column: string, field: string, state = 'available', - coverage?: { entities: number; covered: number }, + coverage = { entities: 1, covered: 1 }, ): DatasetEnrichment => ({ column, field, token: `${column}_${field}`, state, coverage }); +/** An options document holding `fields`, every other member empty. */ +const outputOptions = (fields: Partial): DatasetOutputOptions => ({ + capability_version: 'test', + columns: [], + formats: [], + bundle: { state: 'available' }, + enrichments: [], + long_layout_defaults: [], + long_layout_totals: [], + pivots: [], + groupby: { state: 'available' }, + ...fields, +}); + +const column = (name: string) => ({ name, kind: 'dimension', droppable: false, distinct: null }); + // A flow answer's options: its frame columns, and extras on both role columns // (the recipient side partial), as the options door for "Humanitarian aid // from Germany" answers them. -const flowOptions: DatasetOutputOptions = { - columns: [ - { name: 'stat_var' }, - { name: 'financingProvider' }, - { name: 'financingRecipient' }, - { name: 'time_period' }, - { name: 'value' }, - ], +const flowOptions = outputOptions({ + columns: ['stat_var', 'financingProvider', 'financingRecipient', 'time_period', 'value'].map( + column, + ), enrichments: [ enrichment('financingProvider', 'iso3'), enrichment('financingProvider', 'type'), enrichment('financingRecipient', 'iso3', 'partial', { entities: 145, covered: 125 }), enrichment('financingRecipient', 'label', 'unavailable'), ], -}; +}); const flowDimensions = [ { label: 'financingProvider', count: 1, axis: 'origin' as const }, @@ -84,14 +96,14 @@ test('mintColumns posts only the remembered extras this answer offers, as tokens test('resolveChoices keeps a remembered format only where the answer renders it', () => { const remembered = { format: 'xlsx' as const, bundle: true, enrichment: [] }; // Over Excel's row limit, with the bundle switched off for the deployment. - const large: DatasetOutputOptions = { + const large = outputOptions({ formats: [ { name: 'csv', state: 'available' }, { name: 'xlsx', state: 'unavailable' }, { name: 'parquet', state: 'available' }, ], bundle: { state: 'unavailable' }, - }; + }); const narrowed = resolveChoices(remembered, large); @@ -138,7 +150,7 @@ test.describe('enrichmentGroups heading for a column without a flow axis', () => for (const [name, column, entities, heading] of cases) { test(name, () => { const [group] = enrichmentGroups({ - options: { enrichments: [enrichment(column, 'iso3')] }, + options: outputOptions({ enrichments: [enrichment(column, 'iso3')] }), dimensions: [{ label: column, count: 1 }], entities, }); @@ -159,13 +171,13 @@ test('enrichment rows read their field in words and a partial one its coverage', }); test('the extras list leaves out a column the long layout already writes', () => { - const options: DatasetOutputOptions = { + const options = outputOptions({ enrichments: [ enrichment('financingRecipient', 'party_kind'), enrichment('financingRecipient', 'iso3'), ], long_layout_defaults: ['financingRecipient_label', 'financingRecipient_party_kind'], - }; + }); assert.deepEqual( enrichmentGroups({ options, dimensions: flowDimensions }).flatMap((group) => @@ -177,17 +189,16 @@ test('the extras list leaves out a column the long layout already writes', () => mintColumns(['financingRecipient_party_kind', 'financingRecipient_iso3'], options), ['financingRecipient_iso3'], ); - // An API that predates the field offers everything it lists. + // A long layout that writes no defaults offers everything it lists. assert.equal( - enrichmentGroups({ options: { ...options, long_layout_defaults: undefined } })[0]?.entries - .length, + enrichmentGroups({ options: { ...options, long_layout_defaults: [] } })[0]?.entries.length, 2, ); }); test.describe('totalsNote says where the totals of the roles in long_layout_totals go', () => { const base = { dimensions: flowDimensions, bundleOffered: true }; - const withTotals: DatasetOutputOptions = { long_layout_totals: ['financingRecipient'] }; + const withTotals = outputOptions({ long_layout_totals: ['financingRecipient'] }); const cases: [string, Parameters[0], RegExp | null][] = [ [ 'a bundle', @@ -207,10 +218,14 @@ test.describe('totalsNote says where the totals of the roles in long_layout_tota ['Parquet', { ...base, options: withTotals, format: 'parquet', bundle: true }, null], [ 'no role set apart', - { ...base, options: { long_layout_totals: [] }, format: 'csv', bundle: true }, + { + ...base, + options: outputOptions({ long_layout_totals: [] }), + format: 'csv', + bundle: true, + }, null, ], - ['an older API', { ...base, options: {}, format: 'csv', bundle: true }, null], ]; for (const [name, input, expected] of cases) { diff --git a/src/lib/search/test/fixtures.test.ts b/src/lib/search/test/fixtures.test.ts index 8e2a1f2..ab5b4fe 100644 --- a/src/lib/search/test/fixtures.test.ts +++ b/src/lib/search/test/fixtures.test.ts @@ -1,9 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- these tests walk the raw wire envelope shape, pre-flatten (see searchClient.ts's own WireSpec) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- these tests walk the raw wire envelope shape, pre-flatten, which the fixtures hold as `unknown` */ import assert from 'node:assert/strict'; import test from 'node:test'; import { flattenSpec, normalizeResponse, unflattenSpec } from '../searchClient.ts'; import { primaryStatVarLabel, statVarsUnreadable } from '../view.ts'; import { fixtureNames, fixtures, specFixtureNames } from './fixtures/index.ts'; +import { definite, noData, readable, resolution } from './fixtures/resolution.ts'; +import { knownPart, type Spec } from '../types.ts'; /** * Fixture-set invariant tests: real captured `/api/search/resolve` @@ -41,8 +43,8 @@ for (const name of fixtureNames) { test(`${name}: only a resolution carries parts, and every normalized part carries operations`, () => { const normalized = normalizeResponse(body); - if (body.kind !== 'resolution') { - assert.equal(normalized.parts, undefined); + if (normalized.kind !== 'resolution') { + assert.equal('parts' in normalized, false); return; } assert.ok(normalized.parts.length > 0); @@ -122,14 +124,16 @@ for (const name of specFixtureNames) { }); test(`${name}: flattenSpec reads every spec without setting stat_vars_unreadable`, () => { - const normalized = normalizeResponse(body); + const normalized = resolution(body); for (const part of normalized.parts) { + const known = knownPart(part); const specs = [ - part.spec, - ...(part.candidates?.specs ?? []), - ...(part.no_data?.nearest_real ?? []), - part.no_data?.flagged_match, - ].filter(Boolean); + known?.status === 'definite' ? known.spec : null, + ...(known?.status === 'candidates' ? known.candidates.specs : []), + ...(known?.status === 'no_data' + ? [...(known.no_data.nearest_real ?? []), known.no_data.flagged_match] + : []), + ].filter((spec): spec is Spec => !!spec); assert.ok(specs.length > 0); for (const spec of specs) { @@ -146,7 +150,7 @@ for (const name of specFixtureNames) { test(`${name}: unflattenSpec gives back the wire spec flattenSpec was given`, () => { for (const spec of rawSpecsIn(body)) { - assert.deepEqual(unflattenSpec(flattenSpec(spec)), spec); + assert.deepEqual(unflattenSpec(readable(flattenSpec(spec))), spec); } }); } @@ -167,7 +171,7 @@ test('declined-out-of-scope: normalizes to the same body, reason and empty scope const normalized = normalizeResponse(body); assert.equal(normalized, body); - assert.equal(normalized.kind, 'declined'); + assert.ok(normalized.kind === 'declined'); assert.equal(normalized.reason, 'out_of_scope'); assert.deepEqual(normalized.scope, { place: null }); }); @@ -177,29 +181,28 @@ test('catalogue-kenya: normalizes to the same body, scoped to the confirmed coun const normalized = normalizeResponse(body); assert.equal(normalized, body); - assert.equal(normalized.kind, 'catalogue'); + assert.ok(normalized.kind === 'catalogue'); assert.deepEqual(normalized.scope.place, { ref: 'country/KEN', label: 'Kenya' }); }); test('definite-rank-operation: the definite part keeps its rank operation and flattens its spec', () => { - const normalized = normalizeResponse(fixtures['definite-rank-operation']); - const [part] = normalized.parts; + const normalized = resolution(fixtures['definite-rank-operation']); + const part = definite(normalized.parts[0]); - assert.equal(part.status, 'definite'); assert.deepEqual(part.operations, [{ verb: 'rank', phrase: 'top 5 donors', over: 'origin' }]); - assert.equal(part.spec.resolved, undefined, 'flattenSpec spreads `resolved` flat'); + assert.equal('resolved' in part.spec, false, 'flattenSpec spreads `resolved` flat'); assert.ok(part.spec.identity, 'flattenSpec keeps the definition identity'); }); test('entity-ambiguous: operations default to empty and each option rides through', () => { - const normalized = normalizeResponse(fixtures['entity-ambiguous']); - const [part] = normalized.parts; + const normalized = resolution(fixtures['entity-ambiguous']); + const part = noData(normalized.parts[0]); assert.deepEqual(part.operations, []); assert.equal(part.no_data.reason, 'entity_ambiguous'); assert.deepEqual( - part.no_data.options.map((option: any) => - option.data_query.parts[0].entities.map((entity: any) => entity.dcid ?? null), + part.no_data.options?.map((option) => + option.data_query.parts[0]?.entities?.map((entity) => entity.dcid ?? null), ), [ ['country/COD', 'country/FRA'], @@ -207,7 +210,7 @@ test('entity-ambiguous: operations default to empty and each option rides throug ], ); assert.deepEqual( - part.no_data.ambiguous_entities[0].candidates.map((candidate: any) => candidate.ref), + part.no_data.ambiguous_entities?.[0]?.candidates.map((candidate) => candidate.ref), ['country/COD', 'country/COG'], ); }); diff --git a/src/lib/search/test/fixtures/ambiguous-congo.ts b/src/lib/search/test/fixtures/ambiguous-congo.ts index a7b29b1..42827d9 100644 --- a/src/lib/search/test/fixtures/ambiguous-congo.ts +++ b/src/lib/search/test/fixtures/ambiguous-congo.ts @@ -2,9 +2,8 @@ * Trimmed from two real captured `/api/search/resolve` responses, both * entity_ambiguous on "Congo" (DRC vs. Republic of Congo). Neither capture's * own deployment sends `no_data.ambiguous_entities` yet, so that field is - * added by hand here, in the shape the API ships (models/outcomes.py's - * AmbiguousEntityGroup/AmbiguousEntityCandidate): `{text, candidates: - * [{dcid, label, kind}]}`. The two real Congo dcids (country/COD, + * added by hand here, typed by the generated `NoData`: `{text, candidates: + * [{ref, label, kind}]}`. The two real Congo dcids (country/COD, * country/COG) and the candidate labels are the ones the captures' own * `rendered_sentence` already names ("Congo [DRC]"/"Congo [Republic]"). * The "ODA to the Congo" refusal also carries `no_data.options`, one ready diff --git a/src/lib/search/test/fixtures/dataset-documents.ts b/src/lib/search/test/fixtures/dataset-documents.ts new file mode 100644 index 0000000..568a73d --- /dev/null +++ b/src/lib/search/test/fixtures/dataset-documents.ts @@ -0,0 +1,26 @@ +/** + * Datasets API document parts, built to the generated wire types so a test names only the members + * it reads. + */ + +import type { ArtifactEntryModel, DefinitionModel } from '@developmentfinance/client/wire'; + +/** One listed file; `url` and `artifact_digest` default from the digest. */ +export const artifact = ({ + artifact_digest = 'sha256:test', + ...rest +}: Pick & Partial): ArtifactEntryModel => ({ + artifact_digest, + bytes: 1, + media_type: 'text/csv', + created_at: '2026-10-01T00:00:00Z', + tier: 'working', + url: `/api/datasets/spec_a1/cv_b2/files/${artifact_digest}`, + ...rest, +}); + +/** A definition holding `identity` under `spec_id`. */ +export const definition = ( + identity: DefinitionModel['identity'], + spec_id = 'spec_a1', +): DefinitionModel => ({ schema_version: 1, spec_id, identity }); diff --git a/src/lib/search/test/fixtures/resolution.ts b/src/lib/search/test/fixtures/resolution.ts new file mode 100644 index 0000000..bb92456 --- /dev/null +++ b/src/lib/search/test/fixtures/resolution.ts @@ -0,0 +1,48 @@ +import { normalizeResponse } from '../../searchClient.ts'; +import { knownPart, type Part, type PartSpec, type Resolution, type Spec } from '../../types.ts'; +import { statVarsUnreadable } from '../../view.ts'; + +/** A resolve body normalised as the page reads it. Throws when the body is a + * catalogue or declined answer, so a test that expects a resolution fails + * at the fixture instead of on a missing `parts`. */ +export function resolution(body: unknown): Resolution { + const response = normalizeResponse(body); + + if (response.kind !== 'resolution') { + throw new Error(`expected a resolution body, got kind "${response.kind}"`); + } + return response; +} + +const statusError = (expected: string, part: Part | null | undefined) => + new Error(`expected a ${expected} part, got ${part ? `"${part.status}"` : 'none'}`); + +/** `part` as a definite part; throws for any other status. */ +export function definite(part: Part | null | undefined) { + const known = knownPart(part); + + if (known?.status !== 'definite') throw statusError('definite', part); + return known; +} + +/** `part` as a candidates part; throws for any other status. */ +export function candidates(part: Part | null | undefined) { + const known = knownPart(part); + + if (known?.status !== 'candidates') throw statusError('candidates', part); + return known; +} + +/** `part` as a no_data part; throws for any other status. */ +export function noData(part: Part | null | undefined) { + const known = knownPart(part); + + if (known?.status !== 'no_data') throw statusError('no_data', part); + return known; +} + +/** `spec` as a Spec the client read; throws for one `flattenSpec` flagged unreadable. */ +export function readable(spec: PartSpec): Spec { + if (statVarsUnreadable(spec)) throw new Error(`expected a readable spec, got ${spec.spec_id}`); + return spec; +} diff --git a/src/lib/search/test/fixtures/served-answer.ts b/src/lib/search/test/fixtures/served-answer.ts index 2d0b644..a1168a3 100644 --- a/src/lib/search/test/fixtures/served-answer.ts +++ b/src/lib/search/test/fixtures/served-answer.ts @@ -6,10 +6,11 @@ * response's own lists first, then each later `slot_options` event). */ -import { keyRef, normalizeResponse } from '../../searchClient.ts'; +import { keyRef } from '../../searchClient.ts'; import type { RefinePart } from '../../refineMenu.ts'; import type { SentenceToken } from '../../types.ts'; import { mergeSlotOptionsEntry, type SlotOptionsEntry } from '../../view.ts'; +import { resolution } from './resolution.ts'; export type ServedAnswer = { plan: SentenceToken[]; @@ -19,7 +20,7 @@ export type ServedAnswer = { }; export function servedAnswer(body: unknown, events: unknown = []): ServedAnswer { - const response = normalizeResponse(body); + const response = resolution(body); const parts: RefinePart[] = response.parts.flatMap((part: any, index: number) => part.status === 'definite' ? [{ index, variableText: part.variable_text ?? null, spec: part.spec }] @@ -40,5 +41,10 @@ export function servedAnswer(body: unknown, events: unknown = []): ServedAnswer {} as Record>, ); - return { plan: response.sentence_plan, partCount: response.parts.length, parts, optionsBySpec }; + return { + plan: response.sentence_plan ?? [], + partCount: response.parts.length, + parts, + optionsBySpec, + }; } diff --git a/src/lib/search/test/method.test.ts b/src/lib/search/test/method.test.ts index 5c762fa..fee374c 100644 --- a/src/lib/search/test/method.test.ts +++ b/src/lib/search/test/method.test.ts @@ -4,9 +4,13 @@ import test from 'node:test'; import { withFacetEdit } from '../promote.ts'; import { METHOD_REF, refineItems, refineKey } from '../refineMenu.ts'; -import { normalizeResponse } from '../searchClient.ts'; import { statingTokenIndex } from '../departures.ts'; -import type { BaseIdentity, IdentityFacets, SentenceToken, SpecObservations } from '../types.ts'; +import type { + BaseIdentity, + IdentityFacetBinding, + SentenceToken, + SpecObservations, +} from '../types.ts'; import { ANY_METHOD_LABEL, flattenRows, @@ -20,8 +24,9 @@ import { } from '../view.ts'; import { methodBound, methodUnbound } from './fixtures/index.ts'; import { servedAnswer } from './fixtures/served-answer.ts'; +import { definite, resolution } from './fixtures/resolution.ts'; -const specOf = (body: unknown): any => normalizeResponse(body).parts[0].spec; +const specOf = (body: unknown): any => definite(resolution(body).parts[0]).spec; /** The unbound fixture's spec as a StatVar with recorded values and one * projection facet would arrive. The served series' facet states no method @@ -317,7 +322,7 @@ test('sourceSlotOptions still reads only the provenance axis', () => { const options = sourceSlotOptions(specOf(methodUnbound)); assert.deepEqual( - options.map((option) => option.value.ref.ref), + options.map((option) => option.value.ref?.ref), [ 'dc/base/India_NationalAccountsStatistics', 'dc/base/India_RBIStateDomesticProduct_AggCountry', @@ -486,7 +491,7 @@ test('method labels read in sentence case while the posted ref keeps the dcid', // `pnpm typecheck` checks this file: each @ts-expect-error fails the build // if the types stop refusing the line under it. test('the identity types take a null ref on measurementMethod and unit only', () => { - const facets: IdentityFacets = { + const facets: Partial = { measurementMethod: { state: 'value', ref: null }, unit: { state: 'value', ref: null }, // @ts-expect-error -- every facet carries a provenance, so a null ref names nothing diff --git a/src/lib/search/test/operations.test.ts b/src/lib/search/test/operations.test.ts index c92c0ed..1fea861 100644 --- a/src/lib/search/test/operations.test.ts +++ b/src/lib/search/test/operations.test.ts @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { operationButtonLabel } from '../operations.ts'; -import { normalizeResponse } from '../searchClient.ts'; import type { Part } from '../types.ts'; +import { resolution } from './fixtures/resolution.ts'; import { fixtures } from './fixtures/index.ts'; test('operationButtonLabel names each verb', () => { @@ -21,10 +21,11 @@ test('operationButtonLabel names each verb', () => { 'Ask Studio for a report', ], ); + assert.equal(operationButtonLabel('tally'), 'Open in Studio'); }); test("the rank fixture's operation names its Studio button", () => { - const [part]: Part[] = normalizeResponse(fixtures['definite-rank-operation']).parts; + const [part]: Part[] = resolution(fixtures['definite-rank-operation']).parts; assert.equal(part.status, 'definite'); assert.equal(operationButtonLabel(part.operations[0].verb), 'Rank in Studio'); diff --git a/src/lib/search/test/parsedEdit.test.ts b/src/lib/search/test/parsedEdit.test.ts index 726afc9..31a150f 100644 --- a/src/lib/search/test/parsedEdit.test.ts +++ b/src/lib/search/test/parsedEdit.test.ts @@ -15,7 +15,7 @@ import { odaToCongoNoData, odaToCongoRestatement, } from './fixtures/ambiguous-congo.ts'; -import { normalizeResponse } from '../searchClient.ts'; +import { resolution } from './fixtures/resolution.ts'; import { fixtures } from './fixtures/index.ts'; import type { NoData, ParsedPart, RoleAxis, SlotAxis } from '../types.ts'; @@ -202,7 +202,7 @@ test('resolveTypedEdit is null for a how token even with a restatement present', // --------------------------------------------------------------------------- const congoPart = () => { - const [part] = normalizeResponse(fixtures['entity-ambiguous']).parts; + const [part] = resolution(fixtures['entity-ambiguous']).parts; return part as { no_data: NoData; restatement: ParsedPart }; }; diff --git a/src/lib/search/test/permalink.test.ts b/src/lib/search/test/permalink.test.ts index 4a4c353..f3e0894 100644 --- a/src/lib/search/test/permalink.test.ts +++ b/src/lib/search/test/permalink.test.ts @@ -27,7 +27,7 @@ const identityEdit: PermalinkEdit = { variable_text: 'ODA disbursements', identity: { identity_version: 'spec1', - shape: {}, + shape: { kind: 'statvar_schema' }, slots: [], entities: { origin: ['country/FRA'] }, requested_window: { kind: 'all' }, @@ -57,7 +57,7 @@ const multiEntityEdit: PermalinkEdit = { variable_text: 'ODA to recipients', identity: { identity_version: 'spec1', - shape: {}, + shape: { kind: 'statvar_schema' }, slots: [], entities: { origin: ['country/FRA'], destination: ['country/KEN', 'country/ETH'] }, requested_window: { kind: 'all' }, diff --git a/src/lib/search/test/permalinkEdits.test.ts b/src/lib/search/test/permalinkEdits.test.ts index 3de63f8..4eec919 100644 --- a/src/lib/search/test/permalinkEdits.test.ts +++ b/src/lib/search/test/permalinkEdits.test.ts @@ -10,7 +10,7 @@ const identity: DatasetResubmitEntry = { variable_text: 'ODA disbursements', identity: { identity_version: 'spec1', - shape: {}, + shape: { kind: 'statvar_schema' }, slots: [], entities: { origin: ['country/FRA'] }, requested_window: { kind: 'all' }, @@ -45,7 +45,7 @@ const yearEdit: DatasetResubmitEntry = { variable_text: 'GDP growth', identity: { identity_version: 'spec1', - shape: {}, + shape: { kind: 'statvar_schema' }, slots: [], entities: {}, requested_window: { kind: 'range', start_year: 2010 }, diff --git a/src/lib/search/test/promote.test.ts b/src/lib/search/test/promote.test.ts index 4b34a23..f677b2e 100644 --- a/src/lib/search/test/promote.test.ts +++ b/src/lib/search/test/promote.test.ts @@ -6,7 +6,7 @@ import type { BaseIdentity } from '../types.ts'; const identity: BaseIdentity = { identity_version: 'spec1', - shape: {}, + shape: { kind: 'statvar_schema' }, slots: [], entities: { origin: ['country/FRA'] }, requested_window: { kind: 'all' }, @@ -86,7 +86,7 @@ test('an unbound where binding drops the role from the posted identity', () => { 'destination', ); - assert.equal('destination' in edited.entities, false); + assert.equal('destination' in (edited.entities ?? {}), false); }); test('withEntityEdit binds a picked dcid, single- or multi-select alike', () => { @@ -129,6 +129,6 @@ test('an unbind edit -- dcid null or an empty Apply selection -- drops the role' for (const { description, selection } of cases) { const edited = withEntityEdit(identity, 'origin', selection); - assert.equal('origin' in edited.entities, false, description); + assert.equal('origin' in (edited.entities ?? {}), false, description); } }); diff --git a/src/lib/search/test/readings.test.ts b/src/lib/search/test/readings.test.ts index 0917a7e..b60572f 100644 --- a/src/lib/search/test/readings.test.ts +++ b/src/lib/search/test/readings.test.ts @@ -83,6 +83,15 @@ test('readingsSummary reads the whole summary plan, byline included', () => { ); }); +test('readingsSummary reads a token with no segment as heading, the wire default', () => { + const [lead, ...rest] = summaryPlan(); + + assert.equal( + readingsSummary([{ ...lead, segment: undefined }, ...rest]), + readingsSummary(summaryPlan()), + ); +}); + test("foldReadings folds a candidates part's summary into one what-token named by its variable text", () => { const folded = foldReadings(summaryPlan(0), [ { status: 'candidates', variable_text: 'poverty reduction grants' }, diff --git a/src/lib/search/test/searchClient.test.ts b/src/lib/search/test/searchClient.test.ts index ec4e9f6..5d41aa5 100644 --- a/src/lib/search/test/searchClient.test.ts +++ b/src/lib/search/test/searchClient.test.ts @@ -19,7 +19,10 @@ import { resolveSuggestion, type SearchEvent, } from '../searchClient.ts'; -import type { Warning } from '../types.ts'; +import type { SlotOption, Spec as WireSpec } from '@developmentfinance/client/wire'; + +import type { Part, SpecIdentity, Warning } from '../types.ts'; +import { definite, readable, resolution } from './fixtures/resolution.ts'; import { fixtures } from './fixtures/index.ts'; import { currentEntityDcid, @@ -31,15 +34,24 @@ import { statVarsUnreadable, } from '../view.ts'; +/** The smallest identity the options door and a definition take. */ +const OPTIONS_IDENTITY: SpecIdentity = { + identity_version: 'spec1', + shape: { kind: 'statvar_schema' }, + slots: [], + requested_window: { kind: 'all' }, +}; + // A minimal but wire-shaped WireSpec: the definition/resolved split contract -// v1 requires, with a well-formed `stat_vars`. -function wireSpec(statVars: unknown) { +// v1 requires, carrying `statVars` as its `stat_vars`. The tests pass broken +// values here on purpose, which is the one place a body leaves the type. +function wireSpec(statVars: unknown): WireSpec { return { spec_id: 'spec-1', definition: { schema_version: 1, spec_id: 'spec-1', - identity: { identity_version: 'spec1' }, + identity: OPTIONS_IDENTITY, }, resolved: { shape: { @@ -49,20 +61,19 @@ function wireSpec(statVars: unknown) { member_count: 1, }, slots: [], - stat_vars: statVars, + stat_vars: statVars as WireSpec['resolved']['stat_vars'], entities: [], coverage: { kind: 'bare', has_data: true }, - trace: {}, + trace: { resolved_entities: [], resolved_sources: [], slot_filters: [] }, }, }; } test('flattenSpec reads a well-formed stat_vars, with the counts as the wire sends them', () => { - const flat = flattenSpec( - wireSpec({ member_count: 42, member_count_exact: true, served_count: 3 }), + const flat = readable( + flattenSpec(wireSpec({ member_count: 42, member_count_exact: true, served_count: 3 })), ); - assert.equal(statVarsUnreadable(flat), false); assert.equal(flat.stat_vars.member_count, 42); assert.equal(flat.stat_vars.served_count, 3); assert.equal( @@ -113,10 +124,9 @@ test('flattenSpec flags a null stat_vars as unreadable', () => { }); test('flattenSpec flags a spec missing the definition/resolved split as unreadable, rather than passing it through as a flat pre-v1 body', () => { - const flat = flattenSpec({ - spec_id: 'spec-1', - stat_vars: ['dcid/GdpNominal'], - }); + // A flat pre-v1 body, which the wire type rules out. + const preV1 = { spec_id: 'spec-1', stat_vars: ['dcid/GdpNominal'] }; + const flat = flattenSpec(preV1 as unknown as WireSpec); assert.equal(statVarsUnreadable(flat), true); }); @@ -133,7 +143,7 @@ test('entityOptions posts no resolved_stat_var_dcids and asks for include_covera ); await entityOptions({ - identity: { identity_version: 'spec1' }, + identity: OPTIONS_IDENTITY, scope: 'peers', }); @@ -253,7 +263,7 @@ test('entityOptions sends observationAbout in for.role when role is omitted (a n ); await entityOptions({ - identity: { identity_version: 'spec1' }, + identity: OPTIONS_IDENTITY, scope: 'peers', }); @@ -275,7 +285,7 @@ test('entityOptions sends a directional role verbatim in for.role: origin', asyn ); await entityOptions({ - identity: { identity_version: 'spec1' }, + identity: OPTIONS_IDENTITY, scope: 'peers', role: 'origin', }); @@ -298,7 +308,7 @@ test('entityOptions sends a directional role verbatim in for.role: destination ( ); await entityOptions({ - identity: { identity_version: 'spec1' }, + identity: OPTIONS_IDENTITY, scope: 'children', role: 'destination', }); @@ -336,7 +346,7 @@ test("entityOptions reports the door's own truncated flag from the response", as ); const result = await entityOptions({ - identity: { identity_version: 'spec1' }, + identity: OPTIONS_IDENTITY, scope: 'peers', }); @@ -345,18 +355,10 @@ test("entityOptions reports the door's own truncated flag from the response", as // --- placeOptionValues (the place_options SSE event's own "parse" step) -- -// Shaped from dfo-apis events.py's PlaceOptionsEvent.options: list[SlotOption] -// (models/options.py's SlotOption -- value: SlotValue, coverage), SlotValue's -// own ref: GraphRef (models/grammar.py -- {ref, label}). A captured -// place_options frame for "Population of Kenya" would carry Kenya's own -// peers this way, coverage populated (the event always sets include_coverage -// true). -const capturedPlaceOptionsEvent = { - kind: 'place_options', - spec_id: 'spec-1', - role: 'observationAbout', - scope: 'peers', - anchor_ref: 'country/KEN', +// A place_options frame for "Population of Kenya" carries Kenya's peers as +// wire SlotOptions, coverage populated (the event always sets +// include_coverage true). +const capturedPlaceOptionsEvent: { options: SlotOption[]; truncated: boolean } = { options: [ { value: { ref: { ref: 'country/GHA', label: 'Ghana' }, value_kind: 'entity' }, @@ -405,55 +407,46 @@ test('placeOptionValues returns an empty list for an empty/absent items array', }); test('flattenSpec reads a SlotOptions payload carrying truncated (post-drift-correction shape)', () => { - const wire = { - spec_id: 'spec-1', - definition: { - schema_version: 1, - spec_id: 'spec-1', - identity: { identity_version: 'spec1' }, - }, - resolved: { - shape: { - shape_id: 'shape-1', - label: 'Development finance', - slot_keys: [], - member_count: 1, + const wire = wireSpec({ member_count: 1, member_count_exact: true, served_count: 1 }); + + wire.resolved.slot_options = [ + { + key: { + axis: 'how', + property: { ref: 'crsPurpose', label: 'purpose' }, + label: 'purpose', }, - slots: [], - slot_options: [ - { - key: { - axis: 'how', - property: { ref: 'crsPurpose', label: 'purpose' }, - label: 'purpose', - }, - options: [{ value: { value_kind: 'enum_value' } }], - truncated: false, - source: 'seed', - }, - ], - stat_vars: { member_count: 1, member_count_exact: true, served_count: 1 }, - entities: [], - coverage: { kind: 'bare', has_data: true }, - trace: {}, + options: [{ value: { value_kind: 'enum_value' } }], + truncated: false, + source: 'seed', }, - }; + ]; - const flat = flattenSpec(wire); + const [options] = flattenSpec(wire).slot_options ?? []; - assert.equal(flat.slot_options[0].truncated, false); - assert.equal('complete' in flat.slot_options[0], false); + assert.equal(options?.truncated, false); + assert.ok(options && !('complete' in options)); }); test('isLevelScopedOptions is true once relation is populated on the list', () => { assert.equal( - isLevelScopedOptions([{ value: { ref: { ref: 'x', label: 'X' } }, relation: 'root' }]), + isLevelScopedOptions([ + { + value: { value_kind: 'enum_value', ref: { ref: 'x', label: 'X' } }, + relation: 'root', + }, + ]), true, ); }); test('isLevelScopedOptions is false for a flat/seed option list (relation absent)', () => { - assert.equal(isLevelScopedOptions([{ value: { ref: { ref: 'x', label: 'X' } } }]), false); + assert.equal( + isLevelScopedOptions([ + { value: { value_kind: 'enum_value', ref: { ref: 'x', label: 'X' } } }, + ]), + false, + ); }); test('isLevelScopedOptions is false for an empty option list', () => { @@ -461,13 +454,16 @@ test('isLevelScopedOptions is false for an empty option list', () => { }); test("partitionLevelOptions splits a bound node's neighbours into one parent and its children", () => { - const parent = { - value: { ref: { ref: 'infectious', label: 'Infectious disease control' } }, + const parent: SlotOption = { + value: { + value_kind: 'enum_value', + ref: { ref: 'infectious', label: 'Infectious disease control' }, + }, relation: 'parent', member_count: 1204, }; - const child = { - value: { ref: { ref: 'tb', label: 'Tuberculosis control' } }, + const child: SlotOption = { + value: { value_kind: 'enum_value', ref: { ref: 'tb', label: 'Tuberculosis control' } }, relation: 'child', member_count: 88, }; @@ -480,9 +476,17 @@ test("partitionLevelOptions splits a bound node's neighbours into one parent and }); test('partitionLevelOptions reads every entry as a root when the slot is unbound or its code system is flat', () => { - const roots = [ - { value: { ref: { ref: 'a', label: 'A' } }, relation: 'root', member_count: 5 }, - { value: { ref: { ref: 'b', label: 'B' } }, relation: 'root', member_count: 2 }, + const roots: SlotOption[] = [ + { + value: { value_kind: 'enum_value', ref: { ref: 'a', label: 'A' } }, + relation: 'root', + member_count: 5, + }, + { + value: { value_kind: 'enum_value', ref: { ref: 'b', label: 'B' } }, + relation: 'root', + member_count: 2, + }, ]; const partition = partitionLevelOptions(roots); @@ -496,29 +500,18 @@ test('entitySlotOptions reads the requested directional axis rather than assumin const options = [ { value: { value_kind: 'entity' as const, ref: { ref: 'donor-1', label: 'Donor' } } }, ]; + const kenya = [ + { value: { value_kind: 'entity' as const, ref: { ref: 'country/KEN', label: 'Kenya' } } }, + ]; const entries = { 'spec-1': [ { role: 'origin' as const, options, truncated: false }, - { - role: 'destination' as const, - options: [ - { - value: { - value_kind: 'entity' as const, - ref: { ref: 'country/KEN', label: 'Kenya' }, - }, - }, - ], - truncated: true, - }, + { role: 'destination' as const, options: kenya, truncated: true }, ], }; assert.deepEqual(entitySlotOptions({ spec_id: 'spec-1' }, entries, 'origin'), options); - assert.equal( - entitySlotOptions({ spec_id: 'spec-1' }, entries, 'destination')[0]?.value.ref?.ref, - 'country/KEN', - ); + assert.deepEqual(entitySlotOptions({ spec_id: 'spec-1' }, entries, 'destination'), kenya); }); test('entitySlotOptions returns nothing when the requested directional axis is absent', () => { @@ -558,8 +551,8 @@ test('currentEntityDcid is undefined with no entity in the requested role', () = }); test('mergeLateWarnings attaches a done event warning to the definite part carrying its spec_id', () => { - const response = normalizeResponse(fixtures['definite-single']); - const specId = response.parts[0].spec.spec_id; + const response = resolution(fixtures['definite-single']); + const specId = definite(response.parts[0]).spec.spec_id; const merged = mergeLateWarnings(response, [ { @@ -584,12 +577,12 @@ test('mergeLateWarnings attaches a done event warning to the definite part carry }); test('mergeLateWarnings attaches a late warning to a definite part whose warnings key is missing', () => { - const response = normalizeResponse(fixtures['definite-single']); - const specId = response.parts[0].spec.spec_id; + const response = resolution(fixtures['definite-single']); + const specId = definite(response.parts[0]).spec.spec_id; // Part.warnings is typed as required, but the wire can leave the key off a // definite part. Deleting the key exercises that path. An empty array // would not. - delete response.parts[0].warnings; + delete (response.parts[0] as Partial).warnings; const warning = { code: 'observations_timed_out', severity: 'warn' as const, @@ -603,8 +596,8 @@ test('mergeLateWarnings attaches a late warning to a definite part whose warning }); test("mergeLateWarnings keeps a part's existing warnings and appends the late one to them", () => { - const response = normalizeResponse(fixtures['broad-truncated']); - const specId = response.parts[0].spec.spec_id; + const response = resolution(fixtures['broad-truncated']); + const specId = definite(response.parts[0]).spec.spec_id; const before = response.parts[0].warnings.length; const merged = mergeLateWarnings(response, [ @@ -625,8 +618,8 @@ test("mergeLateWarnings keeps a part's existing warnings and appends the late on }); test('mergeLateWarnings dedupes a done warning identical (code + message + spec_id) to one the part already carries', () => { - const response = normalizeResponse(fixtures['broad-truncated']); - const specId = response.parts[0].spec.spec_id; + const response = resolution(fixtures['broad-truncated']); + const specId = definite(response.parts[0]).spec.spec_id; const before: Warning[] = response.parts[0].warnings; const shipped = before.find((warning) => warning.code === 'observations_series_truncated'); @@ -639,8 +632,8 @@ test('mergeLateWarnings dedupes a done warning identical (code + message + spec_ }); test('mergeLateWarnings still appends a done warning sharing a code with one already on the part, when its message differs', () => { - const response = normalizeResponse(fixtures['broad-truncated']); - const specId = response.parts[0].spec.spec_id; + const response = resolution(fixtures['broad-truncated']); + const specId = definite(response.parts[0]).spec.spec_id; const before = response.parts[0].warnings.length; const merged = mergeLateWarnings(response, [ @@ -664,7 +657,7 @@ test('mergeLateWarnings still appends a done warning sharing a code with one alr }); test('mergeLateWarnings drops a warning naming a spec_id no definite part carries (a candidates part, here)', () => { - const response = normalizeResponse(fixtures['multi-candidate']); + const response = resolution(fixtures['multi-candidate']); const merged = mergeLateWarnings(response, [ { @@ -679,8 +672,8 @@ test('mergeLateWarnings drops a warning naming a spec_id no definite part carrie }); test('mergeLateWarnings dedupes two structurally identical warnings arriving in the same done batch', () => { - const response = normalizeResponse(fixtures['definite-single']); - const specId = response.parts[0].spec.spec_id; + const response = resolution(fixtures['definite-single']); + const specId = definite(response.parts[0]).spec.spec_id; const warning = { code: 'observations_timed_out', severity: 'warn' as const, @@ -694,7 +687,7 @@ test('mergeLateWarnings dedupes two structurally identical warnings arriving in }); test('mergeLateWarnings is a no-op, returning the same response instance, when no warning carries a spec_id', () => { - const response = normalizeResponse(fixtures['definite-single']); + const response = resolution(fixtures['definite-single']); const merged = mergeLateWarnings(response, [ { code: 'run_level_signal', severity: 'info', message: 'no spec_id here' }, @@ -704,8 +697,8 @@ test('mergeLateWarnings is a no-op, returning the same response instance, when n }); test('mergeLateWarnings keeps two warnings distinct whose code/message boundary would collide under a space separator', () => { - const response = normalizeResponse(fixtures['definite-single']); - const specId = response.parts[0].spec.spec_id; + const response = resolution(fixtures['definite-single']); + const specId = definite(response.parts[0]).spec.spec_id; // Under a single-space separator both identities would be "a b c ": // first_pair = "a" + " " + "b c", second_pair = "a b" + " " + "c". The real // separator is `\u0000`, so the two stay distinct. @@ -725,8 +718,8 @@ const modelledLate = { }; test('applyDoneEvent folds late warnings into the definite part carrying the spec_id and sets its late departures', () => { - const response = normalizeResponse(fixtures['definite-single']); - const specId = response.parts[0].spec.spec_id; + const response = resolution(fixtures['definite-single']); + const specId = definite(response.parts[0]).spec.spec_id; const warning = { code: 'observations_timed_out', severity: 'warn' as const, @@ -745,8 +738,8 @@ test('applyDoneEvent folds late warnings into the definite part carrying the spe }); test('applyDoneEvent replaces the shipped departures with the late list, whole', () => { - const single = normalizeResponse(fixtures['definite-single']); - const specId = single.parts[0].spec.spec_id; + const single = resolution(fixtures['definite-single']); + const specId = definite(single.parts[0]).spec.spec_id; const shipped = { code: 'entity_unavailable' as const, binding_ref: null, @@ -764,9 +757,9 @@ test('applyDoneEvent replaces the shipped departures with the late list, whole', }); test('applyDoneEvent gives each part sharing a spec_id its own late list', () => { - const single = normalizeResponse(fixtures['definite-single']); + const single = resolution(fixtures['definite-single']); const response = { ...single, parts: [single.parts[0], { ...single.parts[0] }] }; - const specId = single.parts[0].spec.spec_id; + const specId = definite(single.parts[0]).spec.spec_id; const unavailable = { code: 'entity_unavailable' as const, binding_ref: null, @@ -788,13 +781,17 @@ test('applyDoneEvent gives each part sharing a spec_id its own late list', () => }); test('applyDoneEvent touches only the part at part_index when parts share a spec_id', () => { - const single = normalizeResponse(fixtures['definite-single']); + const single = resolution(fixtures['definite-single']); const response = { ...single, parts: [single.parts[0], { ...single.parts[0] }] }; const merged = applyDoneEvent(response, { warnings: [], late_departures: [ - { part_index: 1, spec_id: single.parts[0].spec.spec_id, departures: [modelledLate] }, + { + part_index: 1, + spec_id: definite(single.parts[0]).spec.spec_id, + departures: [modelledLate], + }, ], }); @@ -803,7 +800,7 @@ test('applyDoneEvent touches only the part at part_index when parts share a spec }); test('applyDoneEvent returns the same response when no late departure names a definite part', () => { - const response = normalizeResponse(fixtures['definite-single']); + const response = resolution(fixtures['definite-single']); assert.equal(applyDoneEvent(response, { warnings: [] }), response); assert.equal( @@ -813,7 +810,7 @@ test('applyDoneEvent returns the same response when no late departure names a de { part_index: 0, spec_id: 'spec_elsewhere', departures: [modelledLate] }, { part_index: 5, - spec_id: response.parts[0].spec.spec_id, + spec_id: definite(response.parts[0]).spec.spec_id, departures: [modelledLate], }, ], @@ -839,7 +836,7 @@ test('mintDataset returns the artifacts/manifest of a synchronous 200 unchanged' const { artifacts, manifest } = await mintDataset({ spec_id: 'spec_1', format: 'csv' }); assert.deepEqual(artifacts, [{ url: 'https://x/sync' }]); - assert.equal(manifest.content_version, 'cv_sync'); + assert.equal(manifest?.content_version, 'cv_sync'); }); test('mintDataset repeats a mint the Datasets API refused as busy and retryable', async (t) => { @@ -857,7 +854,7 @@ test('mintDataset repeats a mint the Datasets API refused as busy and retryable' const { manifest } = await mintDataset({ spec_id: 'spec_1', format: 'csv' }); - assert.equal(manifest.content_version, 'cv_sync'); + assert.equal(manifest?.content_version, 'cv_sync'); assert.equal(fetchMock.mock.callCount(), 2); }); @@ -1159,7 +1156,7 @@ for (const name of ['catalogue-kenya', 'declined-out-of-scope'] as const) { } test('mintDataset posts the spec id and definition and no StatVar list', async (t) => { - const spec = normalizeResponse(fixtures['definite-single']).parts[0].spec; + const spec = definite(resolution(fixtures['definite-single']).parts[0]).spec; const fetchMock = t.mock.method( globalThis, 'fetch', @@ -1195,7 +1192,7 @@ test('fetchVersionLink reads a version from its manifest and the latest data fro urls.push(url); return jsonResponse(200, { - definition: { spec_id: 'spec_a1', identity: { identity_version: 'spec1' } }, + definition: { spec_id: 'spec_a1', identity: OPTIONS_IDENTITY }, }); }); diff --git a/src/lib/search/test/searchShareRequest.test.ts b/src/lib/search/test/searchShareRequest.test.ts index c70cef3..d6fb0b3 100644 --- a/src/lib/search/test/searchShareRequest.test.ts +++ b/src/lib/search/test/searchShareRequest.test.ts @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { normalizeResponse } from '../searchClient.ts'; import { readSearchShareRequest, SEARCH_SHARE_MAX_PARTS, searchSharePart, } from '../searchShareRequest.ts'; import type { HydratedPart, Spec } from '../types.ts'; +import { definite, resolution } from './fixtures/resolution.ts'; import { fixtures } from './fixtures/index.ts'; const part = (partIndex: number) => ({ @@ -105,8 +105,11 @@ test('a part reads with its assumptions, [] and null included, and a malformed o text: 'official development assistance', }; const scope = { kind: 'place_scope', binding_ref: 'where:', refs: [], text: 'every country' }; + // The wire's `refs` is optional, so an item without it reads. + const donors = { kind: 'open_role', binding_ref: 'origin:', text: 'every donor' }; assert.deepEqual(reads([oda, scope])?.assumptions, [oda, scope]); + assert.deepEqual(reads([donors])?.assumptions, [donors]); assert.deepEqual(reads([])?.assumptions, []); assert.equal(reads(null)?.assumptions, null); assert.ok(reads(undefined)); @@ -115,6 +118,7 @@ test('a part reads with its assumptions, [] and null included, and a malformed o oda, [{ ...oda, kind: 'guess' }], [{ ...oda, refs: [27] }], + [{ ...oda, refs: null }], [{ ...oda, text: null }], [{ ...oda, note: 'extra' }], [{ ...oda, text: '' }], @@ -143,12 +147,12 @@ test('a part whose identity holds entity_groups is refused', () => { }); test("a definite part shares its Spec's groups beside the identity, and null when it has none", () => { - const [response] = normalizeResponse(fixtures['definite-rank-operation']).parts; + const response = definite(resolution(fixtures['definite-rank-operation']).parts[0]); const hydrated: HydratedPart = { index: 0, variableText: response.variable_text ?? null, part: response, - hydratedSpec: response.spec!, + hydratedSpec: response.spec, observationsPending: false, phase: 'done', editError: null, diff --git a/src/lib/search/test/sentencePlan.test.ts b/src/lib/search/test/sentencePlan.test.ts index a865d6d..03f8b52 100644 --- a/src/lib/search/test/sentencePlan.test.ts +++ b/src/lib/search/test/sentencePlan.test.ts @@ -144,3 +144,11 @@ test('planSentenceText reads the heading alone, leaving out the byline and depar ); assert.equal(planSentenceText(null), ''); }); + +test('planSentenceText reads a token with no segment as heading, the wire default', () => { + const plan = crs038.sentence_plan.map((token): SentenceToken => + token.segment === 'heading' ? { ...token, segment: undefined } : token, + ); + + assert.equal(planSentenceText(plan), planSentenceText(crs038.sentence_plan)); +}); diff --git a/src/lib/search/test/shareDownload.test.ts b/src/lib/search/test/shareDownload.test.ts index f9505ef..1febc0d 100644 --- a/src/lib/search/test/shareDownload.test.ts +++ b/src/lib/search/test/shareDownload.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { SearchShareRef } from '../searchShare'; +import { artifact } from './fixtures/dataset-documents.ts'; import { acceptsHtml, createWaitGate, @@ -153,15 +154,27 @@ function statusOf(outcome: ReturnType): number | nul // A version manifest lists every rendering stored for the version: here an // xlsx bundle and a column-enriched CSV bundle ahead of the plain CSV bundle. const versionFiles = [ - { artifact_digest: 'sha256:stats', url: 'u-stats', rendering: { statistics: 'series' } }, - { artifact_digest: 'sha256:xlsx', url: 'u-xlsx', rendering: { format: 'xlsx', bundle: true } }, - { + artifact({ + artifact_digest: 'sha256:stats', + url: 'u-stats', + rendering: { statistics: 'series' }, + }), + artifact({ + artifact_digest: 'sha256:xlsx', + url: 'u-xlsx', + rendering: { format: 'xlsx', bundle: true }, + }), + artifact({ artifact_digest: 'sha256:cols', url: 'u-cols', rendering: { format: 'csv', bundle: true, columns: ['country_income'] }, - }, - { artifact_digest: 'sha256:csv', url: 'u-csv', rendering: CSV_BUNDLE }, - { artifact_digest: 'sha256:file', url: 'u-file', rendering: { format: 'csv', bundle: false } }, + }), + artifact({ artifact_digest: 'sha256:csv', url: 'u-csv', rendering: CSV_BUNDLE }), + artifact({ + artifact_digest: 'sha256:file', + url: 'u-file', + rendering: { format: 'csv', bundle: false }, + }), ]; test('a snapshot link serves the file its capture pinned, by digest', () => { @@ -298,7 +311,7 @@ const serveLive = ({ waitMs?: number; now?: () => number; }) => { - const pinned: (string | null)[] = []; + const pinned: string[] = []; const answer = serveLiveDownload({ shareId: 'abc', target: liveTarget(rendering), @@ -391,7 +404,7 @@ test('a client that disconnects mid-poll ends its wait quietly and frees its slo const errors = t.mock.method(console, 'error', () => {}); const gate = createWaitGate(1); const client = new AbortController(); - const pinned: (string | null)[] = []; + const pinned: string[] = []; let polls = 0; const fetchImpl = (async (url: string) => { if (!url.includes('/operations/')) { diff --git a/src/lib/search/test/sharedEdit.test.ts b/src/lib/search/test/sharedEdit.test.ts new file mode 100644 index 0000000..11d25fb --- /dev/null +++ b/src/lib/search/test/sharedEdit.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { type SlotSharedEdit, sharedEditTouched, withSharedEdit } from '../sharedEdit.ts'; +import type { HydratedPart, PartSpec, Spec } from '../types.ts'; +import { definite, readable, resolution } from './fixtures/resolution.ts'; +import { fixtures } from './fixtures/index.ts'; + +/** The definite-single part, settled, holding `spec` in place of its own. */ +function partHolding(spec: (read: Spec) => PartSpec): HydratedPart { + const part = definite(resolution(fixtures['definite-single']).parts[0]); + const held = spec(readable(part.spec)); + + return { + index: 0, + variableText: part.variable_text ?? null, + part: { ...part, spec: held }, + hydratedSpec: held, + observationsPending: false, + phase: 'done', + editError: null, + }; +} + +const toGhana: SlotSharedEdit = { + axis: 'where', + binding: { + kind: 'value', + value: { value_kind: 'entity', ref: { ref: 'country/GHA', label: 'Ghana' } }, + }, + entityRole: 'observationAbout', +}; + +// A spec flagged unreadable still takes the edit when it carries the slots and identity the edit +// reads; one carrying neither has nothing to edit. +for (const [name, spec, touched] of [ + ['a readable spec', (read: Spec): PartSpec => read, true], + [ + 'an unreadable spec with its slots', + (read: Spec): PartSpec => ({ ...read, stat_vars_unreadable: true }), + true, + ], + [ + 'an unreadable spec without its slots', + (read: Spec): PartSpec => ({ + spec_id: read.spec_id, + definition: read.definition, + stat_vars_unreadable: true, + }), + false, + ], +] as const) { + test(`a shared where edit ${touched ? 'rewrites' : 'skips'} ${name}`, () => { + const part = partHolding(spec); + + assert.deepEqual(sharedEditTouched([part], toGhana, {}), touched ? [0] : []); + + if (touched) { + const { identity } = readable( + definite(resolution(fixtures['definite-single']).parts[0]).spec, + ); + + assert.ok(identity && part.hydratedSpec); + assert.deepEqual(withSharedEdit(identity, part.hydratedSpec, toGhana).entities, { + observationAbout: ['country/GHA'], + }); + } + }); +} diff --git a/src/lib/search/test/snapshot.test.ts b/src/lib/search/test/snapshot.test.ts index 55ae03c..3fb96cd 100644 --- a/src/lib/search/test/snapshot.test.ts +++ b/src/lib/search/test/snapshot.test.ts @@ -8,7 +8,9 @@ import { PIN_REFUSED, PIN_UNAVAILABLE, pinVersionFrame, + renderingArtifact, } from '../snapshot.ts'; +import { artifact } from './fixtures/dataset-documents.ts'; // The manifest lists a statistics artifact, a Parquet frame, a // column-enriched CSV bundle, then the plain CSV bundle a mint without @@ -90,6 +92,41 @@ test('captureSnapshot mints with the default options', async () => { assert.equal('options' in (calls[0]!.body as object), false); }); +// Each listed rendering sets one MintOptions member beyond format and bundle, so it is a different +// file from the plain one listed after it. A false or null member sets nothing. +for (const [name, listed, asked, plain] of [ + ['columns', { columns: ['country_income'] }, { format: 'csv', bundle: true }, false], + ['pivot', { pivot: { index: ['year'] } }, { format: 'csv', bundle: true }, false], + ['groupby', { groupby: { by: ['stat_var'] } }, { format: 'csv', bundle: true }, false], + ['layout', { layout: 'frame' }, { format: 'parquet', bundle: false }, false], + [ + 'without_party_totals', + { without_party_totals: true }, + { format: 'csv', bundle: true }, + false, + ], + [ + 'a false without_party_totals', + { without_party_totals: false }, + { format: 'csv', bundle: true }, + true, + ], + ['a null layout', { layout: null }, { format: 'parquet', bundle: false }, true], +] as const) { + test(`renderingArtifact ${plain ? 'takes' : 'skips'} a rendering with ${name}`, () => { + const withMember = artifact({ + artifact_digest: 'sha256:member', + rendering: { ...asked, ...listed }, + }); + const bare = artifact({ artifact_digest: 'sha256:plain', rendering: { ...asked } }); + + assert.equal( + renderingArtifact([withMember, bare], asked)?.artifact_digest, + plain ? 'sha256:member' : 'sha256:plain', + ); + }); +} + test('captureSnapshot fails when the mint lists no plain CSV bundle', async () => { for (const listed of [[], artifacts.slice(0, 3)]) { const { calls, fetchImpl } = datasetsApi(200, listed); diff --git a/src/lib/search/test/studioHandoff.test.ts b/src/lib/search/test/studioHandoff.test.ts index 2425342..0a19737 100644 --- a/src/lib/search/test/studioHandoff.test.ts +++ b/src/lib/search/test/studioHandoff.test.ts @@ -9,25 +9,38 @@ import test from 'node:test'; import { MAX_PROPS_BYTES } from '../../events/limits.ts'; import { EVENT_SAMPLE, searchEventName, searchEventProps } from '../searchEvents.ts'; -import { flattenSpec, normalizeResponse, unflattenSpec } from '../searchClient.ts'; +import { flattenSpec, unflattenSpec } from '../searchClient.ts'; import { planSentenceText } from '../sentencePlan.ts'; import { handoffEdited, handoffQuestion, handoffSeeds, studioHref } from '../studioHandoff.ts'; -import type { HydratedPart, Operation, Part } from '../types.ts'; +import { + type HydratedPart, + knownPart, + type Operation, + type Part, + type PartSpec, + type SentenceToken, + type Spec, +} from '../types.ts'; +import { readable, resolution } from './fixtures/resolution.ts'; import { fixtures, type FixtureName } from './fixtures/index.ts'; /** The fixture's normalized parts as a settled run holds them. */ function hydratedParts(name: FixtureName): HydratedPart[] { - const parts: Part[] = normalizeResponse(fixtures[name]).parts; - - return parts.map((part, index) => ({ - index, - variableText: part.variable_text ?? null, - part, - hydratedSpec: part.status === 'definite' ? (part.spec ?? null) : null, - observationsPending: false, - phase: 'done', - editError: null, - })); + const parts: Part[] = resolution(fixtures[name]).parts; + + return parts.map((part, index) => { + const known = knownPart(part); + + return { + index, + variableText: part.variable_text ?? null, + part, + hydratedSpec: known?.status === 'definite' ? known.spec : null, + observationsPending: false, + phase: 'done', + editError: null, + }; + }); } const wireSpecOf = (name: FixtureName, index = 0) => (fixtures[name] as any).parts[index].spec; @@ -89,6 +102,8 @@ test('handoffSeeds gives a folded part one seed with the operations of both', () operations: [ { verb: 'rank', phrase: 'top 5 donors', over: 'origin' }, { verb: 'growth', phrase: 'change since 2015', over: null }, + // A verb Studio's wire does not name stays out of the seed. + { verb: 'tally', phrase: 'tally them', over: null }, ], }, }; @@ -101,22 +116,35 @@ test('handoffSeeds gives a folded part one seed with the operations of both', () ); }); -test('handoffSeeds skips a spec with no definition, which Studio would refuse', () => { - const [part] = hydratedParts('definite-single'); - const bare: HydratedPart = { - ...part, - part: { ...part.part!, spec: { ...part.part!.spec!, definition: null } as any }, - }; - - assert.deepEqual(handoffSeeds([bare]), []); -}); +for (const [name, spec] of [ + ['with no definition', (read: Spec): PartSpec => ({ ...read, definition: null })], + [ + 'the site could not read', + (read: Spec): PartSpec => ({ ...read, stat_vars_unreadable: true }), + ], +] as const) { + test(`handoffSeeds skips a spec ${name}, which Studio would refuse`, () => { + const [part] = hydratedParts('definite-single'); + const definite = knownPart(part.part); + + assert.ok(definite?.status === 'definite'); + + const left: HydratedPart = { + ...part, + part: { ...definite, spec: spec(readable(definite.spec)) }, + }; + + assert.equal(handoffSeeds([part]).length, 1); + assert.deepEqual(handoffSeeds([left]), []); + }); +} test('unflattenSpec keeps the trace flattenSpec also reads as `resolution`', () => { const wire = structuredClone(wireSpecOf('definite-single')); wire.resolved.trace = { resolved_entities: [{ ref: 'country/KEN' }] }; - assert.deepEqual(unflattenSpec(flattenSpec(wire)), wire); + assert.deepEqual(unflattenSpec(readable(flattenSpec(wire))), wire); }); test('studioHref carries the question, and the token when there is one', () => { @@ -134,7 +162,7 @@ test('a Studio click after a place and year edit hands over the edited question query, edited: false, sentence: planSentenceText( - normalizeResponse(fixtures['definite-explain-operation']).sentence_plan, + resolution(fixtures['definite-explain-operation']).sentence_plan, ), }), query, @@ -144,7 +172,7 @@ test('a Studio click after a place and year edit hands over the edited question }); // The edit's answer replaces every part; the user has an edit on record. - const edited = normalizeResponse(fixtures['definite-explain-edited']); + const edited = resolution(fixtures['definite-explain-edited']); const after = hydratedParts('definite-explain-edited'); const question = handoffQuestion({ query, @@ -189,9 +217,9 @@ test('a Studio click on an opened share saved after an edit hands over the sente // original query, and the opener has made no edit of their own. const query = 'Why did the population of Ethiopia fall in 2023?'; // The plan's byline stays off the question. - const opened = normalizeResponse(fixtures['definite-explain-edited']); - const plan = [ - ...opened.sentence_plan, + const opened = resolution(fixtures['definite-explain-edited']); + const plan: SentenceToken[] = [ + ...(opened.sentence_plan ?? []), { kind: 'slot', text: ' Persons.', axis: 'unit', segment: 'byline', entity_refs: [] }, ]; const edited = handoffEdited({ editCount: 0, fromShare: true }); diff --git a/src/lib/search/test/tokenAbout.test.ts b/src/lib/search/test/tokenAbout.test.ts index f32cdd0..adcf2b5 100644 --- a/src/lib/search/test/tokenAbout.test.ts +++ b/src/lib/search/test/tokenAbout.test.ts @@ -8,9 +8,9 @@ import { singleStatVarSpec, tokenAbout, } from '../tokenAbout.ts'; -import { normalizeResponse } from '../searchClient.ts'; import { priceBasis } from '../unitLabel.ts'; import type { SentenceToken } from '../types.ts'; +import { definite, resolution } from './fixtures/resolution.ts'; import { fixtures } from './fixtures/index.ts'; const slot = (fields: Partial): SentenceToken => ({ @@ -183,7 +183,7 @@ test('a continent or UN subregion group counts the countries and territories it test('the served Africa region reads as its countries and territories', () => { const body = fixtures['region-group'] as { sentence_plan: SentenceToken[] }; const token = body.sentence_plan.find((candidate) => candidate.axis === 'where'); - const [{ spec }] = normalizeResponse(body).parts; + const { spec } = definite(resolution(body).parts[0]); assert.ok(token); const about = tokenAbout(token, spec); diff --git a/src/lib/search/test/unitPicker.test.ts b/src/lib/search/test/unitPicker.test.ts index f2681be..336906e 100644 --- a/src/lib/search/test/unitPicker.test.ts +++ b/src/lib/search/test/unitPicker.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildDatasetEntries } from '../datasetEntries.ts'; -import { normalizeResponse } from '../searchClient.ts'; import { currencyName } from '../tokenAbout.ts'; import type { HydratedPart, UnitConversion } from '../types.ts'; import { @@ -27,12 +26,13 @@ import { unitDebt, unitHealth, } from './fixtures/unit-conversion.ts'; +import { definite, resolution } from './fixtures/resolution.ts'; -const specOf = (body: unknown): any => normalizeResponse(body).parts[0].spec; +const specOf = (body: unknown): any => definite(resolution(body).parts[0]).spec; /** `body`'s parts as the page hydrates them, each spec as served. */ const hydratedParts = (body: unknown): HydratedPart[] => - normalizeResponse(body).parts.map((part: any, index: number) => ({ + resolution(body).parts.map((part: any, index: number) => ({ index, variableText: part.variable_text ?? null, part, diff --git a/src/lib/search/test/versionLink.test.ts b/src/lib/search/test/versionLink.test.ts index c75e4fd..1767a95 100644 --- a/src/lib/search/test/versionLink.test.ts +++ b/src/lib/search/test/versionLink.test.ts @@ -6,8 +6,10 @@ import { parseVersionLink, storedDownload, versionLinkHref, + type VersionLinkDocument, type VersionLinkOutcome, } from '../versionLink.ts'; +import { artifact, definition } from './fixtures/dataset-documents.ts'; const params = (query: string) => new URLSearchParams(query); @@ -52,12 +54,18 @@ const sharedOf = (outcome: VersionLinkOutcome | null) => test('outcomeFromDocument opens a version as a snapshot share dated by its earliest artifact', () => { const link = { specId: 'spec_a1', contentVersion: 'cv_b2' }; const outcome = outcomeFromDocument(link, { - definition: { spec_id: 'spec_a1', identity }, - citation: { sources: [{ label: 'OECD CRS' }, { label: 'IMF WEO' }] }, + content_version: 'cv_b2', + definition: definition(identity), + citation: { + text: '', + sources: [ + { ref: 'dcid:oecd', label: 'OECD CRS' }, + { ref: 'dcid:imf', label: 'IMF WEO' }, + ], + }, artifacts: [ - { created_at: '2026-10-03T12:00:00Z' }, - { created_at: '2026-10-02T09:00:00Z' }, - {}, + artifact({ created_at: '2026-10-03T12:00:00Z', rendering: {} }), + artifact({ created_at: '2026-10-02T09:00:00Z', rendering: {} }), ], }); @@ -89,9 +97,7 @@ test('outcomeFromDocument opens a latest-data link live', () => { const shared = sharedOf( outcomeFromDocument( { specId: 'spec_a1', contentVersion: null }, - { - definition: { spec_id: 'spec_a1', identity }, - }, + { content_version: 'cv_b2', definition: definition(identity) }, ), ); @@ -103,35 +109,37 @@ test('outcomeFromDocument opens a latest-data link live', () => { test('outcomeFromDocument refuses a document without a definition identity', () => { const link = { specId: 'spec_a1', contentVersion: 'cv_b2' }; - assert.equal(outcomeFromDocument(link, { definition: { spec_id: 'spec_a1' } }), null); - assert.equal(outcomeFromDocument(link, { definition: null }), null); + for (const body of [{ definition: { spec_id: 'spec_a1' } }, { definition: null }]) { + assert.equal(outcomeFromDocument(link, body as unknown as VersionLinkDocument), null); + } }); const pinned = { identity_version: 'spec1', shape: { kind: 'stat_var' } }; const stored = [ - { url: '/api/datasets/spec_a1/cv_b2/files/sha256:s0', rendering: { statistics: 'series' } }, - { - url: '/api/datasets/spec_a1/cv_b2/files/sha256:f1', + artifact({ + artifact_digest: 'sha256:s0', + rendering: { statistics: 'series' }, + }), + artifact({ + artifact_digest: 'sha256:f1', rendering: { format: 'csv', bundle: false }, - }, - { - url: '/api/datasets/spec_a1/cv_b2/files/sha256:b1', + }), + artifact({ artifact_digest: 'sha256:b1', rendering: { format: 'csv', bundle: true }, - }, + }), ]; -const frame = { - url: '/api/datasets/spec_a1/cv_b2/files/sha256:fr', +const frame = artifact({ artifact_digest: 'sha256:fr', media_type: 'application/vnd.apache.parquet', rendering: { format: 'frame' }, -}; +}); test('outcomeFromDocument offers a pinned-StatVar version as its stored bundle', () => { assert.deepEqual( outcomeFromDocument( { specId: 'spec_a1', contentVersion: 'cv_b2' }, - { definition: { spec_id: 'spec_a1', identity: pinned }, artifacts: stored }, + { content_version: 'cv_b2', definition: definition(pinned), artifacts: stored }, ), { kind: 'download_only', @@ -144,7 +152,11 @@ test('outcomeFromDocument offers a data file when a pinned-StatVar version has n assert.deepEqual( outcomeFromDocument( { specId: 'spec_a1', contentVersion: 'cv_b2' }, - { definition: { spec_id: 'spec_a1', identity: pinned }, artifacts: stored.slice(0, 2) }, + { + content_version: 'cv_b2', + definition: definition(pinned), + artifacts: stored.slice(0, 2), + }, ), { kind: 'download_only', @@ -169,7 +181,8 @@ for (const [name, link, artifacts] of [ test(`outcomeFromDocument offers no download for a pinned-StatVar ${name}`, () => { assert.deepEqual( outcomeFromDocument(link, { - definition: { spec_id: 'spec_a1', identity: pinned }, + content_version: 'cv_b2', + definition: definition(pinned), artifacts: [...artifacts], }), { kind: 'download_only', download: null }, @@ -186,7 +199,7 @@ test('storedDownload picks the bundle, else the first data file, by its stable l assert.deepEqual(storedDownload({ artifacts: stored.slice(0, 2) }), { url: '/api/datasets/spec_a1/cv_b2/files/sha256:f1', format: 'csv', - digest: null, + digest: 'sha256:f1', }); assert.equal(storedDownload({ artifacts: stored.slice(0, 1) }), null); }); @@ -196,6 +209,6 @@ test('storedDownload skips the canonical frame', () => { assert.deepEqual(storedDownload({ artifacts: [stored[1], frame, stored[0]] }), { url: '/api/datasets/spec_a1/cv_b2/files/sha256:f1', format: 'csv', - digest: null, + digest: 'sha256:f1', }); }); diff --git a/src/lib/search/test/view.test.ts b/src/lib/search/test/view.test.ts index 2c105b9..07e8fcf 100644 --- a/src/lib/search/test/view.test.ts +++ b/src/lib/search/test/view.test.ts @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { keyRef, normalizeResponse } from '../searchClient.ts'; +import { keyRef } from '../searchClient.ts'; import { tokenPart } from '../sentencePlan.ts'; +import { definite, noData, readable, resolution } from './fixtures/resolution.ts'; import { fixtures } from './fixtures/index.ts'; import { noDataResponse, wireSpec } from './fixtures/no-data.ts'; import { servedAnswer } from './fixtures/served-answer.ts'; @@ -50,7 +51,16 @@ import { splitCurrentPick, } from '../view.ts'; import type { EntityOptionsBySpec, PlaceOptionScopeResult, PlaceOptionsEntry } from '../view.ts'; -import type { SentenceToken } from '../types.ts'; +import type { SentenceToken, Slot, SlotOption } from '../types.ts'; +import type { EntityOption } from '../searchClient.ts'; + +/** A slot option binding `ref`, or the one that binds no value. */ +const slotOption = (ref?: string, label = ref): SlotOption => ({ + value: { + value_kind: 'enum_value', + ref: ref === undefined ? null : { ref, label: label ?? ref }, + }, +}); test('entity_options reducer preserves the SSE truncation flag for a destination', () => { const entries = mergeEntityOptionsEntry({}, 'spec-1', { @@ -240,7 +250,7 @@ test('noDataCallout hands the flagged match the full question as editText when t const response = noDataResponse('role_incompatible', null, { flagged_match: wireSpec('spec_flagged'), }); - const part = normalizeResponse(response).parts[0]; + const part = resolution(response).parts[0]; const callout = noDataCallout(part, QUESTION); @@ -257,7 +267,7 @@ test('noDataCallout hands the flagged match its own rendered sentence as editTex const response = noDataResponse('role_incompatible', null, { flagged_match: wireSpec('spec_flagged', { rendered_sentence: sentence }), }); - const part = normalizeResponse(response).parts[0]; + const part = resolution(response).parts[0]; const callout = noDataCallout(part, QUESTION); @@ -269,7 +279,7 @@ test('noDataCallout offers the alternate match, not Studio, for an only_alternat const response = noDataResponse('no_matching_slice', 'only_alternate_has_data', { alternate_match: wireSpec('spec_alternate'), }); - const part = normalizeResponse(response).parts[0]; + const part = resolution(response).parts[0]; const callout = noDataCallout(part, QUESTION); @@ -279,7 +289,7 @@ test('noDataCallout offers the alternate match, not Studio, for an only_alternat test('noDataCallout falls back to Studio when neither a flagged nor alternate match exists and nearest_real is empty', () => { const response = noDataResponse('variable_not_resolved', null, {}); - const part = normalizeResponse(response).parts[0]; + const part = resolution(response).parts[0]; assert.deepEqual(noDataCallout(part, QUESTION), { kind: 'studio' }); }); @@ -288,7 +298,7 @@ test('noDataCallout is null when nearest_real is non-empty, leaving the nearest- const response = noDataResponse('no_observations', null, { nearest_real: [wireSpec('spec_near')], }); - const part = normalizeResponse(response).parts[0]; + const part = resolution(response).parts[0]; assert.equal(noDataCallout(part, QUESTION), null); }); @@ -594,7 +604,7 @@ test('slotOptionsEntry returns an empty, non-truncated entry when the spec_id is test('slotOptionsEntry returns an empty, non-truncated entry when the spec is present but the key is absent', () => { const bySpec = { - 'spec-1': { 'what:crsPurpose': { options: [{ value: {} }], truncated: true } }, + 'spec-1': { 'what:crsPurpose': { options: [slotOption()], truncated: true } }, }; assert.deepEqual(slotOptionsEntry(bySpec, 'spec-1', 'where:'), { @@ -605,7 +615,7 @@ test('slotOptionsEntry returns an empty, non-truncated entry when the spec is pr test('slotOptionsEntry reads back a populated entry, truncated true preserved', () => { const entry = { - options: [{ value: { ref: { ref: 'country/KEN', label: 'Kenya' } } }], + options: [slotOption('country/KEN', 'Kenya')], truncated: true, }; const bySpec = { 'spec-1': { 'where:': entry } }; @@ -615,7 +625,7 @@ test('slotOptionsEntry reads back a populated entry, truncated true preserved', test('slotOptionsEntry does not leak a same-named key from a different spec_id', () => { const bySpec = { - 'spec-1': { 'where:': { options: [{ value: {} }], truncated: false } }, + 'spec-1': { 'where:': { options: [slotOption()], truncated: false } }, }; assert.deepEqual(slotOptionsEntry(bySpec, 'spec-2', 'where:'), { @@ -627,7 +637,7 @@ test('slotOptionsEntry does not leak a same-named key from a different spec_id', // --- mergeSlotOptionsEntry ---------------------------------------------- test('mergeSlotOptionsEntry seeds a brand new spec/key pair into an empty map', () => { - const entry = { options: [{ value: {} }], truncated: false }; + const entry = { options: [slotOption()], truncated: false }; assert.deepEqual(mergeSlotOptionsEntry({}, 'spec-1', 'where:', entry), { 'spec-1': { 'where:': entry }, @@ -635,8 +645,8 @@ test('mergeSlotOptionsEntry seeds a brand new spec/key pair into an empty map', }); test('mergeSlotOptionsEntry adds a second key under a spec without dropping a sibling key already there', () => { - const first = { options: [{ value: { ref: { ref: 'country/KEN' } } }], truncated: false }; - const second = { options: [{ value: { ref: { ref: 'crsPurpose/110' } } }], truncated: true }; + const first = { options: [slotOption('country/KEN')], truncated: false }; + const second = { options: [slotOption('crsPurpose/110')], truncated: true }; const bySpec = mergeSlotOptionsEntry({}, 'spec-1', 'where:', first); assert.deepEqual(mergeSlotOptionsEntry(bySpec, 'spec-1', 'how:crsPurpose', second), { @@ -645,8 +655,8 @@ test('mergeSlotOptionsEntry adds a second key under a spec without dropping a si }); test("mergeSlotOptionsEntry overwrites an existing key's entry, options and truncated both replaced", () => { - const stale = { options: [{ value: { ref: { ref: 'a' } } }], truncated: true }; - const fresh = { options: [{ value: { ref: { ref: 'b' } } }], truncated: false }; + const stale = { options: [slotOption('a')], truncated: true }; + const fresh = { options: [slotOption('b')], truncated: false }; const bySpec = mergeSlotOptionsEntry({}, 'spec-1', 'where:', stale); assert.deepEqual(mergeSlotOptionsEntry(bySpec, 'spec-1', 'where:', fresh), { @@ -655,9 +665,9 @@ test("mergeSlotOptionsEntry overwrites an existing key's entry, options and trun }); test("mergeSlotOptionsEntry leaves a different spec's entries untouched", () => { - const otherSpecEntry = { options: [{ value: {} }], truncated: false }; + const otherSpecEntry = { options: [slotOption()], truncated: false }; const bySpec = mergeSlotOptionsEntry({}, 'spec-1', 'where:', otherSpecEntry); - const newEntry = { options: [{ value: {} }], truncated: true }; + const newEntry = { options: [slotOption()], truncated: true }; const next = mergeSlotOptionsEntry(bySpec, 'spec-2', 'where:', newEntry); @@ -708,9 +718,7 @@ test('showGroupHeadings is true for two groups', () => { // exactly the decision Add Criteria's shared renderer makes off it. test("a level-scoped option list stays level-scoped after slotOptionsEntry's lookup, so a shared renderer would route it through the hierarchy branch", () => { - const levelScoped = [ - { value: { ref: { ref: 'crsPurpose/100' } }, relation: 'root', member_count: 12 }, - ]; + const levelScoped = [{ ...slotOption('crsPurpose/100'), relation: 'root', member_count: 12 }]; const bySpec = mergeSlotOptionsEntry({}, 'spec-1', 'how:crsPurpose', { options: levelScoped, truncated: false, @@ -723,7 +731,7 @@ test("a level-scoped option list stays level-scoped after slotOptionsEntry's loo }); test("a flat option list stays flat after slotOptionsEntry's lookup, so a shared renderer would route it through the flat branch", () => { - const flat = [{ value: { ref: { ref: 'country/KEN' } } }]; + const flat = [slotOption('country/KEN')]; const bySpec = mergeSlotOptionsEntry({}, 'spec-1', 'where:', { options: flat, truncated: false, @@ -733,7 +741,7 @@ test("a flat option list stays flat after slotOptionsEntry's lookup, so a shared }); test('mergeSlotOptionsEntry then slotOptionsEntry round-trips a truncated entry exactly, not just its option list', () => { - const entry = { options: [{ value: { ref: { ref: 'crsTypeOfFlow/110' } } }], truncated: true }; + const entry = { options: [slotOption('crsTypeOfFlow/110')], truncated: true }; const bySpec = mergeSlotOptionsEntry({}, 'spec-1', 'how:crsTypeOfFlow', entry); assert.deepEqual(slotOptionsEntry(bySpec, 'spec-1', 'how:crsTypeOfFlow'), entry); @@ -742,8 +750,8 @@ test('mergeSlotOptionsEntry then slotOptionsEntry round-trips a truncated entry // --- optionMuted ------------------------------------------------------ test('optionMuted is true for a flat option the probe confirmed has no data', () => { - const option = { - value: { ref: { ref: 'country/LVA' } }, + const option: SlotOption = { + ...slotOption('country/LVA'), coverage: { kind: 'bare', has_data: false }, }; @@ -751,25 +759,25 @@ test('optionMuted is true for a flat option the probe confirmed has no data', () }); test('optionMuted is false for an observed option, and false for a never-probed one', () => { - const observed = { - value: { ref: { ref: 'country/DEU' } }, + const observed: SlotOption = { + ...slotOption('country/DEU'), coverage: { kind: 'bare', has_data: true }, }; - const unprobed = { value: { ref: { ref: 'country/KEN' } } }; + const unprobed: SlotOption = slotOption('country/KEN'); assert.equal(optionMuted(observed), false); assert.equal(optionMuted(unprobed), false); }); test('optionMuted reads a probed-zero level-scoped parent the same way it reads a flat option (F2)', () => { - const options = [ + const options: SlotOption[] = [ { - value: { ref: { ref: 'crsPurpose/100' } }, + ...slotOption('crsPurpose/100'), relation: 'parent', member_count: 40, coverage: { kind: 'bare', has_data: false }, }, - { value: { ref: { ref: 'crsPurpose/110' } }, relation: 'child', member_count: 12 }, + { ...slotOption('crsPurpose/110'), relation: 'child', member_count: 12 }, ]; const partition = partitionLevelOptions(options); @@ -777,10 +785,10 @@ test('optionMuted reads a probed-zero level-scoped parent the same way it reads }); test('optionMuted reads a probed-zero level-scoped child the same way it reads a flat option', () => { - const options = [ - { value: { ref: { ref: 'crsPurpose/100' } }, relation: 'parent', member_count: 40 }, + const options: SlotOption[] = [ + { ...slotOption('crsPurpose/100'), relation: 'parent', member_count: 40 }, { - value: { ref: { ref: 'crsPurpose/110' } }, + ...slotOption('crsPurpose/110'), relation: 'child', member_count: 12, coverage: { kind: 'bare', has_data: false }, @@ -794,51 +802,48 @@ test('optionMuted reads a probed-zero level-scoped child the same way it reads a // --- rankOptionsByCoverage ---------------------------------------------- test('rankOptionsByCoverage passes a flat list through unchanged when nothing carries a probed verdict', () => { - const options = [ - { value: { ref: { ref: 'country/DEU' } } }, - { value: { ref: { ref: 'country/KEN' } } }, - ]; + const options: SlotOption[] = [slotOption('country/DEU'), slotOption('country/KEN')]; assert.deepEqual(rankOptionsByCoverage(options, 'country/KEN'), options); }); test('rankOptionsByCoverage ranks a flat option list richest-first and demotes a probed-zero option to the end', () => { - const options = [ - { value: { ref: { ref: 'country/LVA' } }, coverage: { kind: 'bare', has_data: false } }, + const options: SlotOption[] = [ + { ...slotOption('country/LVA'), coverage: { kind: 'bare', has_data: false } }, { - value: { ref: { ref: 'country/DEU' } }, + ...slotOption('country/DEU'), coverage: { kind: 'exact', has_data: true, observation_count: 18 }, }, { - value: { ref: { ref: 'country/KEN' } }, + ...slotOption('country/KEN'), coverage: { kind: 'exact', has_data: true, observation_count: 5 }, }, ]; assert.deepEqual( - rankOptionsByCoverage(options, null).map((o) => o.value.ref.ref), + rankOptionsByCoverage(options, null).map((o) => o.value.ref?.ref), ['country/DEU', 'country/KEN', 'country/LVA'], ); }); test('rankOptionsByCoverage keeps the bound option visible ahead of other probed-zero options, even when it is itself probed-zero', () => { - const options = [ - { value: { ref: { ref: 'country/LVA' } }, coverage: { kind: 'bare', has_data: false } }, - { value: { ref: { ref: 'country/CZE' } }, coverage: { kind: 'bare', has_data: false } }, + const options: SlotOption[] = [ + { ...slotOption('country/LVA'), coverage: { kind: 'bare', has_data: false } }, + { ...slotOption('country/CZE'), coverage: { kind: 'bare', has_data: false } }, { - value: { ref: { ref: 'country/DEU' } }, + ...slotOption('country/DEU'), coverage: { kind: 'exact', has_data: true, observation_count: 18 }, }, ]; assert.deepEqual( - rankOptionsByCoverage(options, 'country/CZE').map((o) => o.value.ref.ref), + rankOptionsByCoverage(options, 'country/CZE').map((o) => o.value.ref?.ref), ['country/DEU', 'country/CZE', 'country/LVA'], ); }); test('rankOptionsByCoverage keeps a probed-zero current pick visible on the flat EntityOption shape PlaceScopeEditor feeds it', () => { - const options = [ + const options: EntityOption[] = [ { dcid: 'country/LVA', label: 'Latvia', coverage: { kind: 'bare', has_data: false } }, { dcid: 'country/CZE', label: 'Czechia', coverage: { kind: 'bare', has_data: false } }, { @@ -1079,7 +1084,7 @@ function kenyaFixtureSpec() { }, ]; - return normalizeResponse(body).parts[0].spec; + return readable(definite(resolution(body).parts[0]).spec); } const kenyaWhereToken: SentenceToken = { @@ -1670,8 +1675,8 @@ test('operationProgressText formats rows written against the estimate', () => { }); test("a single-part entity_ambiguous plan routes its wording token to that wording's candidates", () => { - const response = normalizeResponse(fixtures['world-bank-ambiguous']); - const plan: SentenceToken[] = response.sentence_plan; + const response = resolution(fixtures['world-bank-ambiguous']); + const plan = response.sentence_plan ?? []; const slots = plan.filter((token) => token.kind === 'slot'); assert.equal(slots.length, 1); @@ -1681,7 +1686,7 @@ test("a single-part entity_ambiguous plan routes its wording token to that wordi assert.equal(partIndex, 0); assert.equal(wording.role, 'origin'); assert.deepEqual( - ambiguousWordingGroup(wording, response.parts[0].no_data)?.options.map( + ambiguousWordingGroup(wording, noData(response.parts[0]).no_data)?.options.map( (option) => option.label, ), [ @@ -1692,7 +1697,7 @@ test("a single-part entity_ambiguous plan routes its wording token to that wordi }); test('ambiguousWordingGroup refuses the variable slot and an entity read-back', () => { - const { no_data: noData } = normalizeResponse(fixtures['world-bank-ambiguous']).parts[0]; + const { no_data: refusal } = noData(resolution(fixtures['world-bank-ambiguous']).parts[0]); const token: SentenceToken = { kind: 'slot', text: 'World Bank', @@ -1700,19 +1705,23 @@ test('ambiguousWordingGroup refuses the variable slot and an entity read-back', role: 'origin', }; - assert.ok(ambiguousWordingGroup(token, noData)); - assert.equal(ambiguousWordingGroup({ ...token, axis: 'what' }, noData), null); - assert.equal(ambiguousWordingGroup({ ...token, entity_refs: ['WorldBank'] }, noData), null); - assert.equal(ambiguousWordingGroup({ ...token, kind: 'text' }, noData), null); - assert.equal(ambiguousWordingGroup({ ...token, text: 'IFC' }, noData), null); + assert.ok(ambiguousWordingGroup(token, refusal)); + assert.equal(ambiguousWordingGroup({ ...token, axis: 'what' }, refusal), null); + assert.equal(ambiguousWordingGroup({ ...token, entity_refs: ['WorldBank'] }, refusal), null); + assert.equal(ambiguousWordingGroup({ ...token, kind: 'text' }, refusal), null); + assert.equal(ambiguousWordingGroup({ ...token, text: 'IFC' }, refusal), null); }); test("ambiguousEntityMenuTitle titles the menu by the ambiguous wording's role", () => { - const part = normalizeResponse(fixtures['entity-ambiguous']).parts[0]; + const part = noData(resolution(fixtures['entity-ambiguous']).parts[0]); + const { restatement } = part; + + assert.ok(restatement); + const withRole = (roleText: string | null) => ({ ...part, restatement: { - ...part.restatement, + ...restatement, entities: [ { text: 'Congo', role_text: roleText }, { text: 'France', role_text: 'from' }, @@ -1728,8 +1737,12 @@ test("ambiguousEntityMenuTitle titles the menu by the ambiguous wording's role", }); test('ambiguousEntityMenuTitle names every ambiguous wording a shared menu offers', () => { - const part = normalizeResponse(fixtures['entity-ambiguous']).parts[0]; - const [congo] = part.no_data.options; + const part = noData(resolution(fixtures['entity-ambiguous']).parts[0]); + const { restatement } = part; + const [congo] = part.no_data.options ?? []; + + assert.ok(restatement && congo); + const bothAmbiguous = { ...part, no_data: { @@ -1740,7 +1753,7 @@ test('ambiguousEntityMenuTitle names every ambiguous wording a shared menu offer ], }, restatement: { - ...part.restatement, + ...restatement, entities: [ { text: 'World Bank', role_text: 'from' }, { text: 'Georgia', role_text: 'to' }, @@ -1752,16 +1765,20 @@ test('ambiguousEntityMenuTitle names every ambiguous wording a shared menu offer }); test('ambiguousEntityMenuTitle reads a restatement whose entities are null', () => { - const part = normalizeResponse(fixtures['entity-ambiguous']).parts[0]; - const withoutEntities = { ...part, restatement: { ...part.restatement, entities: null } }; + const part = noData(resolution(fixtures['entity-ambiguous']).parts[0]); + const { restatement } = part; + + assert.ok(restatement); + + const withoutEntities = { ...part, restatement: { ...restatement, entities: null } }; assert.equal(ambiguousEntityMenuTitle(withoutEntities), 'Select Place'); }); // --- rootGroups ------------------------------------------------------------ -const levelOption = (dcid: string, relation: string, parent?: string) => ({ - value: { ref: { ref: dcid, label: dcid } }, +const levelOption = (dcid: string, relation: string, parent?: string): SlotOption => ({ + ...slotOption(dcid), relation, ...(parent ? { parent } : {}), }); @@ -1780,8 +1797,8 @@ test('rootGroups puts each child under the root its parent names, in wire order' assert.deepEqual( groups.map(({ root, children }) => [ - root.value.ref.ref, - children.map((child) => child.value.ref.ref), + root.value.ref?.ref, + children.map((child) => child.value.ref?.ref), ]), [ ['crsPurpose/100', ['crsPurpose/120', 'crsPurpose/110']], @@ -1816,7 +1833,7 @@ test('rootGroups reads a root whose one child serves the same rows as that child assert.deepEqual( rootGroups(partitionLevelOptions(options)).map(({ root, children }) => [ - root.value.ref.label, + root.value.ref?.label, root.served_rows, children.length, ]), @@ -1832,15 +1849,14 @@ test('rootGroups reads a root whose one child serves the same rows as that child // --- levelMenu --------------------------------------------------------- -const valueBinding = (dcid: string) => ({ +const valueBinding = (dcid: string): Slot['binding'] => ({ kind: 'value', value: { value_kind: 'taxonomy', ref: { ref: dcid, label: dcid } }, }); -const dcids = (options: { value: { ref: { ref: string } } }[]) => - options.map((option) => option.value.ref.ref); +const dcids = (options: SlotOption[]) => options.map((option) => option.value.ref?.ref); -const boundMenu = (binding: unknown, options: unknown[]) => { +const boundMenu = (binding: Slot['binding'], options: SlotOption[]) => { const menu = levelMenu(binding, options); assert.equal(menu.kind, 'bound'); @@ -1849,19 +1865,25 @@ const boundMenu = (binding: unknown, options: unknown[]) => { }; test('levelMenu groups an unbound slot, or one bound to a set, by root', () => { - const options = [ + const options: SlotOption[] = [ levelOption('CRSPurposeCode100', 'root'), levelOption('CRSPurposeCode110', 'child', 'CRSPurposeCode100'), levelOption('CRSPurposeCode200', 'root'), ]; - for (const binding of [{ kind: 'unbound' }, { kind: 'set', values: [] }, undefined]) { + const bindings: (Slot['binding'] | undefined)[] = [ + { kind: 'unbound' }, + { kind: 'set', values: [] }, + undefined, + ]; + + for (const binding of bindings) { const menu = levelMenu(binding, options); assert.equal(menu.kind, 'unbound'); assert.deepEqual( menu.kind === 'unbound' && - menu.groups.map(({ root, children }) => [root.value.ref.ref, dcids(children)]), + menu.groups.map(({ root, children }) => [root.value.ref?.ref, dcids(children)]), [ ['CRSPurposeCode100', ['CRSPurposeCode110']], ['CRSPurposeCode200', []], @@ -1877,8 +1899,8 @@ test('levelMenu puts a bound non-root between its parent and its children', () = levelOption('CRSPurposeCode100', 'parent'), ]); - assert.equal(menu.up?.value.ref.ref, 'CRSPurposeCode100'); - assert.equal(menu.current.value.ref.ref, 'CRSPurposeCode120'); + assert.equal(menu.up?.value.ref?.ref, 'CRSPurposeCode100'); + assert.equal(menu.current.value.ref?.ref, 'CRSPurposeCode120'); assert.deepEqual(dcids(menu.down), ['CRSPurposeCode122', 'CRSPurposeCode121']); assert.deepEqual(menu.sideways, []); }); @@ -1892,10 +1914,10 @@ test('levelMenu keeps a bound root as the current pick, its children down and th ]); assert.equal(menu.up, null); - assert.equal(menu.current.value.ref.ref, 'CRSPurposeCode100'); + assert.equal(menu.current.value.ref?.ref, 'CRSPurposeCode100'); assert.deepEqual(dcids(menu.down), ['CRSPurposeCode110', 'CRSPurposeCode120']); assert.deepEqual( - menu.sideways.map(({ root, children }) => [root.value.ref.ref, dcids(children)]), + menu.sideways.map(({ root, children }) => [root.value.ref?.ref, dcids(children)]), [ ['CRSPurposeCode200', []], ['CRSPurposeCode300', []], @@ -1909,10 +1931,10 @@ test('levelMenu keeps a childless bound root as the current pick beside the othe levelOption('DAC2aAidType207', 'root'), ]); - assert.equal(menu.current.value.ref.ref, 'DAC2aAidType216'); + assert.equal(menu.current.value.ref?.ref, 'DAC2aAidType216'); assert.deepEqual(menu.down, []); assert.deepEqual( - menu.sideways.map(({ root }) => root.value.ref.ref), + menu.sideways.map(({ root }) => root.value.ref?.ref), ['DAC2aAidType206', 'DAC2aAidType207'], ); }); @@ -1925,7 +1947,7 @@ test("levelMenu takes a flat code system's bound value from the list, with its r assert.equal(menu.current.served_rows, 7); assert.deepEqual( - menu.sideways.map(({ root }) => root.value.ref.ref), + menu.sideways.map(({ root }) => root.value.ref?.ref), ['crsTypeOfFlow/11'], ); }); @@ -1940,10 +1962,10 @@ test('levelMenu keeps every child reachable under its root for a binding the cod levelOption('CRSPurposeCode210', 'child', 'CRSPurposeCode200'), ]); - assert.equal(menu.current.value.ref.ref, 'CRSPurposeCode999'); + assert.equal(menu.current.value.ref?.ref, 'CRSPurposeCode999'); assert.deepEqual(menu.down, []); assert.deepEqual( - menu.sideways.map(({ root, children }) => [root.value.ref.ref, dcids(children)]), + menu.sideways.map(({ root, children }) => [root.value.ref?.ref, dcids(children)]), [ ['CRSPurposeCode100', ['CRSPurposeCode110']], ['CRSPurposeCode200', ['CRSPurposeCode210']], @@ -1976,7 +1998,7 @@ test('slotOffersChoice offers a value binding its other options, and an unbound download: { rows: 1284, rows_exact: true }, }; const one = (served_rows: number | null) => ({ - options: [{ value: { ref: { ref: 'Net' } }, served_rows }], + options: [{ ...slotOption('Net'), served_rows }], truncated: false, }); @@ -1993,7 +2015,7 @@ test('slotOffersChoice offers a value binding its other options, and an unbound binding: { kind: 'set', values: dcids.map((dcid) => ({ ref: { ref: dcid } })) }, }); const members = (...dcids: string[]) => ({ - options: dcids.map((dcid) => ({ value: { ref: { ref: dcid } }, served_rows: null })), + options: dcids.map((dcid) => ({ ...slotOption(dcid), served_rows: null })), truncated: false, }); diff --git a/src/lib/search/tokenAbout.ts b/src/lib/search/tokenAbout.ts index 482b299..70c9f6d 100644 --- a/src/lib/search/tokenAbout.ts +++ b/src/lib/search/tokenAbout.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- reads the same shallow Spec the sentence menus read (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- reads specs as `any`, dereferencing only the members it names and tolerating any that is missing */ /** * The About line at the top of a sentence token's panel: what the token's * current value is, read from the Spec the answer already carries. The line @@ -12,13 +12,20 @@ import { keyRef } from './searchClient.ts'; import { capitalise } from '../capitalise.ts'; -import type { EntityRoleKey, SentenceToken, UnitConversion } from './types.ts'; +import { + type EntityRoleKey, + hasKey, + type Known, + type SentenceToken, + type UnitConversion, +} from './types.ts'; import { priceBasis } from './unitLabel.ts'; import type { PriceBasis } from './unitLabel.ts'; import { axisLabel, formatCount, formatWindow, + observationSeries, provenanceOrigin, rowsLabel, servedSeriesCount, @@ -90,10 +97,8 @@ export const servedSpan = (spec: any): string | null => { export const latestDate = (spec: any): string | null => { const end: unknown = spec?.stat_vars?.data_date_range?.end; const ends: string[] = typeof end === 'string' && end ? [end] : []; - const points: string[] = (spec?.observations?.series ?? []).flatMap((series: any) => - (series?.points ?? []) - .filter((point: any) => point?.value != null) - .map((point: any) => point.date), + const points = observationSeries(spec).flatMap((series) => + series.points.filter((point) => point.value != null).map((point) => point.date), ); return [...ends, ...points].toSorted().at(-1) ?? null; @@ -153,7 +158,10 @@ const measureAbout = ( }; }; -const ROLE_NOUNS: Record = { +const ROLE_NOUNS: Record< + Known, + { one: string; many: string; dimensions: string[] } +> = { origin: { one: 'provider', many: 'providers', dimensions: ['providers'] }, destination: { one: 'recipient', many: 'recipients', dimensions: ['recipients'] }, observationAbout: { one: 'place', many: 'places', dimensions: ['countries', 'places'] }, @@ -169,7 +177,8 @@ const listNames = (names: string[]): string => const entityAbout = (token: SentenceToken, spec: any): TokenAboutModel => { const role: EntityRoleKey = token.role ?? 'observationAbout'; - const nouns = ROLE_NOUNS[role]; + // A role this release does not know reads as the subject role. + const nouns = hasKey(ROLE_NOUNS, role) ? ROLE_NOUNS[role] : ROLE_NOUNS.observationAbout; const entities: any[] = spec?.entities ?? []; const dcids: string[] = token.entity_refs?.length ? token.entity_refs diff --git a/src/lib/search/types.ts b/src/lib/search/types.ts index 9a63f95..2fd5a9a 100644 --- a/src/lib/search/types.ts +++ b/src/lib/search/types.ts @@ -1,1308 +1,314 @@ /** - * Search engine contract — TypeScript mirror. + * Search contract types for the site. * - * Single source of truth for every consumer. These types mirror the Search API - * contract shipped by dfo-apis (authoritative pydantic models; live schema at - * `GET /api/search/schema`). The engine owns the contract; we consume it. When - * the models and these types diverge, the models win and these types must be - * updated (and the schema_version bumped if MAJOR). + * The wire types come from `@developmentfinance/client/wire`, generated from the API's served + * OpenAPI document; this file re-exports the ones the site names and builds three kinds of type on + * top of them: * - * MIRROR: A second, deliberately-shallow mirror lives at `lib/search/searchClient.ts` - * (transport-level event types). Keep both aligned with the engine schema; this file is the fuller one. - */ - -// --------------------------------------------------------------------------- -// Primitives -// --------------------------------------------------------------------------- - -/** Contract v1 (deployed 2026-08-17): an integer version tag. The pre-release - * "1.0" string form is refused with unsupported_schema_version. */ -export type SchemaVersion = number; - -/** The only three outcomes the envelope can carry. */ -export type ResolveStatus = 'definite' | 'candidates' | 'no_data'; - -/** A directional flow endpoint (provider/recipient read-back). Mirrors the - * server's own `RoleAxis` name and values one-for-one (dfo-apis - * dfo_platform/roles.py:17, src/search/models/grammar.py:293). */ -export type RoleAxis = 'origin' | 'destination'; - -/** One currency a unit_not_resolved refusal offers in place of the unit phrase it could - * not resolve. Mirrors dfo-apis src/search/models/outcomes.py's UnitAlternative. */ -export interface UnitAlternative { - /** the value a caller resubmits as facets.unit.request.currency: an ISO 4217 - * code, or a role shape for a role-relative currency phrase */ - currency: string | IdentityCurrencyRole; - /** the currency's display name, or the role's reader-facing noun */ - label: string; -} - -/** - * Mirrors the server's own Ordering (dfo-apis vocab.py) one-for-one: - * intent_rank is the order the status rule offers the readings in, and every - * first answer uses it: the elected reading first, then its rivals in menu - * order, each stating its `basis`. broadest_first is a posted candidates - * entry's own order, kept as posted and stated back. - */ -export type Ordering = 'broadest_first' | 'intent_rank'; - -/** - * Why a part offers a Spec as one of its candidates (dfo-apis vocab.py - * CandidateBasis): `structural`, a reading that differs from the elected one - * only on a stat type, price basis or denominator the request leaves open; - * `nominated`, a reading the model named as a genuine reading of the phrase; - * `twin`, an equivalent reading of the elected one, offered where the elected - * one holds no data; `direction`, the same reading with the other direction - * for a place the request names without one. - */ -export type CandidateBasis = 'structural' | 'nominated' | 'twin' | 'direction'; - -/** A normalised form a question can ask for (dfo-apis vocab.py QuantityForm): - * per person, per a numbered count of people, per area, a rate, or a share - * of a whole. */ -export type QuantityForm = 'per_person' | 'per_head_base' | 'per_area' | 'rate' | 'share'; - -/** Why a no_data outcome was returned (closed enum). */ -export type NoDataReason = - | 'source_not_resolved' - | 'source_ambiguous' - | 'source_code_not_held' - | 'source_code_ambiguous' - | 'source_code_shares_variable' - | 'entity_not_resolved' - | 'entity_ambiguous' - | 'entity_set_too_large' - | 'entity_role_not_admitted' - | 'variable_not_resolved' - | 'role_not_resolved' - | 'unit_not_resolved' - | 'rate_coverage_gap' - | 'no_matching_slice' - | 'binding_mismatch' - | 'denominator_not_available' - | 'no_observations' - | 'facet_not_available' - | 'role_incompatible' - | 'role_signature_incompatible' - | 'qualifier_not_bound'; - -/** - * Present on `NoData.gate` exactly when `flagged_match` or `alternate_match` - * is present, except `role_incompatible`, `qualifier_not_bound` and - * `denominator_not_available`, whose `flagged_match` records a - * graph-confirmed identity with no availability verdict taken. Mirrors dfo-apis `NoDataGate` one-for-one. - */ -export type NoDataGate = - | 'no_member_has_data_at_place' - | 'no_member_asserts_bound_properties' - | 'only_alternate_has_data'; - -/** Warning severity levels. */ -export type WarningSeverity = 'info' | 'warn' | 'degraded'; - -/** Qualitative retrieval confidence for an interpretation. */ -export type Confidence = 'high' | 'medium' | 'low'; - -/** Where a slot's option set came from. */ -export type SlotOptionsSource = 'observed' | 'seed'; - -/** - * Where a level-scoped SlotOption sits relative to its slot's bound value: - * "child"/"parent" narrow or widen one step in a hierarchical code system's - * taxonomy. "root" is a classification root: every root of an unbound - * slot, every other root beside a bound root, or any value of a flat code - * system, so it does not tell bound from unbound. Mirrors the server's - * SlotOptionRelation (dfo-apis src/search/models/vocab.py) one-for-one. - */ -export type SlotOptionRelation = 'child' | 'parent' | 'root'; - -/** - * What one party of a flow is beside the other parties on its side: a - * `party` holds figures of its own, a `total` holds other parties' figures - * ("Africa, Total"), so a sum across the role that counts it counts its - * members twice, and a `residual` holds figures no other party holds. Mirrors - * PartyKind (dfo-apis src/search/models/vocab.py) one-for-one. - */ -export type PartyKind = 'party' | 'total' | 'residual'; - -/** A {ref, label} pair. Every identifier in the contract is a GraphRef. */ -export interface GraphRef { - /** the graph identifier, a dcid */ - ref: string; - /** plain-language display name, read from the graph */ - label: string; -} - -/** The Data Commons a provenance's data comes from: the Observatory's own - * imports, Data Commons by Google, or the UN System Data Commons. Mirrors - * dfo-apis src/search/models/vocab.py DataCommonsId. */ -export type DataCommonsId = 'dfo' | 'base' | 'un_system'; - -/** The organisation behind a provenance, which is the Source node its - * `source` arc names or, on the UN System Data Commons, the agency - * provenance itself. The site calls it the publisher. */ -export interface SourceRef { - ref: string; - label: string; -} - -/** The Data Commons a provenance's data comes from, with its display name - * (Development Finance Observatory, Data Commons by Google, UN System Data - * Commons). */ -export interface DataCommonsRef { - id: DataCommonsId; - label: string; -} - -/** A provenance on the wire (dfo-apis src/search/models/grammar.py - * ProvenanceRef). `source` and `data_commons` come from the origin - * catalogue, and are absent for a provenance the catalogue does not list, - * which then carries its dcid as `label`. */ -export interface ProvenanceRef extends GraphRef { - source?: SourceRef | null; - data_commons?: DataCommonsRef | null; - /** the Graph API route of the provenance's document, - * `/api/graph/documentation/{dcid}` */ - documentation: string; -} - -// --------------------------------------------------------------------------- -// Slots -// --------------------------------------------------------------------------- - -/** The fixed slot-grammar axes the engine defines. */ -export type SlotAxis = 'what' | 'how' | 'where' | 'when' | 'source'; - -export interface SlotKey { - axis: SlotAxis; - /** - * The constraint property that defines this slot when one exists; non-null on - * how/what/where when a specific property drives the axis; null for when, - * source, and the where-axis slot that binds the subject/recipient entity - * directly (no constraint property backs it). - */ - property: GraphRef | null; - /** human label, e.g. "purpose", "provider" */ - label: string; -} - -export interface TimeWindow { - /** absent => open-ended start */ - start_year?: number; - /** absent => open-ended end ("to latest") */ - end_year?: number; -} - -/** - * A requested when-axis signal for identity purposes: either a concrete - * TimeWindow, or the literal "latest" for a query that explicitly asked for - * the most-recent/current value. Distinct from `undefined` (no date signal at - * all) — a resubmit carrying "latest" re-resolves to whatever year is currently - * latest, instead of pinning to the year the original request resolved to. - */ -export type RequestedWindow = TimeWindow | 'latest'; - -export type SlotValueKind = 'entity' | 'enum_value' | 'time_window' | 'source' | 'literal'; - -export interface SlotValue { - /** the graph object bound, when the value is a graph object */ - ref?: GraphRef; - value_kind: SlotValueKind; - /** present iff value_kind === "time_window" */ - time_window?: TimeWindow; - /** present iff value_kind === "literal" */ - literal?: string; -} - -/** Exactly one taxonomy member. */ -export interface BindingValue { - kind: 'value'; - value: SlotValue; -} - -/** Two or more members, all in scope (union). A length-1 set is illegal. */ -export interface BindingSet { - kind: 'set'; - values: SlotValue[]; - /** the group the set was drawn from, when one exists (e.g. "DAC donors") */ - set_ref?: GraphRef; -} - -/** Explicit "whole slot in scope" — NOT null/missing. */ -export interface BindingUnbound { - kind: 'unbound'; -} - -/** The slot's constraint property is NOT present on the resolved SVs (un-decomposed series). */ -export interface BindingAbsent { - kind: 'absent'; -} - -export type Binding = BindingValue | BindingSet | BindingUnbound | BindingAbsent; -export type BindingKind = Binding['kind']; - -export interface Slot { - key: SlotKey; - /** exactly one of the four binding states */ - binding: Binding; -} - -// --------------------------------------------------------------------------- -// Shape -// --------------------------------------------------------------------------- - -export interface Shape { - shape_id: string; - /** plain-language name of what the reading serves: a composed measure label, the served series' name, or the family's name */ - label: string; - // The five structural anchors. All nullable: a custom-DC import may omit any - // five-tuple arc, so the StatVar doesn't assert it. - population_type: GraphRef | null; - measured_property: GraphRef | null; - stat_type: GraphRef | null; - /** structural anchor; present only when the measured property is qualified */ - measurement_qualifier?: GraphRef | null; - /** structural anchor; present only on a ratio or per-capita measure */ - measurement_denominator?: GraphRef | null; - /** the constraint + facet slots this family exposes, in display order */ - slot_keys: SlotKey[]; - /** how many StatVars this shape's current bindings admit, narrowing as a - * slot binds and widening again as one is cleared */ - member_count: number; - /** Hash of the family's members, null when Search did not settle the - * family. The members read is keyed by it, since membership can change - * under the same spec_id. */ - member_digest?: string | null; - /** - * Whether this shape's slots can be edited/refined via construct-and-confirm: - * the engine builds sibling StatVar candidates for the edited slot and confirms - * them against the graph before answering (never a guess). True for both named - * families (e.g. dev_finance_crs_dac, via construct_sv_dcid) and standard/ - * catch-all shapes (via construct_standard_sibling_candidates) alike — the flag - * applies the same way to both. False only for a ratio-backed _AsAFractionOf_ - * standard shape, where no sibling StatVar can be constructed for an edited - * slot. Gate editability on this rather than treating every slot as freely - * editable. Defaults to false. - */ - refine_supported?: boolean; - /** every Data Commons holding admitted data for a member StatVar, in the - * order dfo, base, un_system, and empty when the engine derived the shape - * without its members */ - data_commons?: DataCommonsRef[]; - /** the one source and Data Commons every member's data comes through, for - * a shape outside the Observatory's own imports whose members sit under - * exactly one source */ - upstream?: ShapeUpstream | null; -} - -export interface ShapeUpstream { - source: SourceRef; - data_commons: DataCommonsRef; -} - -// --------------------------------------------------------------------------- -// StatVar / Entity -// --------------------------------------------------------------------------- - -/** Earliest and latest confirmed dates across a Spec's served members. */ -export interface DateRange { - /** earliest confirmed date, e.g. "2012" */ - start?: string | null; - /** latest confirmed date, e.g. "2022" */ - end?: string | null; -} - -/** - * The size of the StatVar population behind a Spec (dfo-apis src/search/ - * models/statvar.py). `member_count` counts the measure family's members - * with data, and `member_count_exact` is false when that count is a floor. - * `served_count` counts the members this Spec serves, stated only once - * membership settled; null means the walk could not finish within its - * budget, so the served set is unverified. `data_date_range` spans the - * served members' data. The members themselves come from - * the Datasets members door (`fetchDefinitionMembers`). - */ -export interface ResolvedStatVars { - member_count: number; - member_count_exact: boolean; - served_count?: number | null; - /** the first and last dates across the served members' data; null when - * membership is unsettled or no member reported a range */ - data_date_range?: DateRange | null; -} - -/** dfo-apis src/search/models/vocab.py:83 EntityRoleKey, the flat role - * string a resolved entity plays. Wider than RoleAxis by the subject - * role ("observationAbout") every non-directional entity carries. */ -export type EntityRoleKey = 'observationAbout' | 'origin' | 'destination'; - -/** - * One resolved entity under Spec.entities, mirroring the engine's own - * ResolvedEntity (dfo-apis src/search/models/entity.py). `ref` is the bare - * dcid string, not a GraphRef, and `role` is the flat EntityRoleKey string. - */ -export interface ResolvedEntity { - /** the resolved entity's dcid — a bare string, not a GraphRef */ - ref: string; - label?: string | null; - /** e.g. Country, AdministrativeArea1, ContinentalUnion, Organization */ - entity_type?: string | null; - role: EntityRoleKey; - confidence?: Confidence | null; -} - -// --------------------------------------------------------------------------- -// Coverage -// --------------------------------------------------------------------------- - -export interface BreadthDim { - /** the dimension, e.g. "providers", "years", "methods", "sources" */ - label: string; - count: number; -} - -export type Coverage = - | { - kind: 'exact'; - has_data: boolean; - observation_count: number; - dimensions?: BreadthDim[]; - window?: TimeWindow; - } - | { kind: 'breadth'; has_data: boolean; dimensions: BreadthDim[]; window?: TimeWindow } - | { kind: 'bare'; has_data: boolean; window?: TimeWindow }; - -export type CoverageKind = Coverage['kind']; - -// --------------------------------------------------------------------------- -// Resolution trace -// --------------------------------------------------------------------------- - -/** Why a slot has its value: the bind call's own election, a code named in - * the query's text, the measure-family election, or a code system's - * declared default. */ -export type BindingProvenance = - 'binder' | 'text_recognized' | 'measure_elected' | 'declared_default'; - -export interface ResolvedFilter { - key: SlotKey; - binding_kind: BindingKind; - /** the bound graph objects (empty for unbound) */ - refs: GraphRef[]; - /** why the slot has this value; absent on an older engine */ - provenance?: BindingProvenance; -} + * - the page shapes `searchClient.normalizeResponse` produces (`Spec`, `Part`, `CandidateSet`, + * `NoData`, `Resolution`), where every nested wire Spec is flattened; + * - the SSE stream payloads the OpenAPI document does not describe (`Frame`, `LateDepartures`); + * - the client-only run state (`PartPhase`, `HydratedPart`, `ExpectedPart`). + * + * String enums on the wire are open unions, so a value the server adds still typechecks. `Known` + * closes one where the site writes a value; code that reads one handles the unknown case. + */ + +import { PART_STATUSES } from '@developmentfinance/client'; +import type * as wire from '@developmentfinance/client/wire'; + +/** The closed part of an open wire enum, holding the values this release names and omitting the + * `string` tail a newer server may fill. */ +export type Known = T extends string ? (string extends T ? never : T) : T; + +/** True when `key` is one of `map`'s own keys, which narrows an open wire value to the closed + * set a site map covers. */ +export function hasKey(map: Record, key: string): key is K { + return Object.hasOwn(map, key); +} + +/** `T` without null and undefined. `NonNullable` intersects with `{}`, which folds an open enum's + * literals into its `string & {}` tail. */ +type Present = Exclude; + +// --------------------------------------------------------------------------- +// Wire types, as generated +// --------------------------------------------------------------------------- + +export type { + Assumption, + BaseIdentity, + CatalogueAnswer, + ConversionCurrency, + DeclinedAnswer, + Departure, + EntityGroup, + EntityOptions, + GraphRef, + IdentityUnitRequest, + MeasureAxisValue, + NoDataDenominator, + NoDataOption, + ObservationSeries, + ObservationsOmission, + OpenConstraintAxisFact, + ParsedPart, + ProvenanceRef, + RelatedMeasure, + ResubmitCandidate as DatasetCandidateIdentity, + ResubmitCandidatesEntry as DatasetCandidatesEntry, + ResubmitIdentityEntry as DatasetIdentityEntry, + ResubmitParsedEntry as DatasetParsedEntry, + ResubmitPlaceholderEntry as DatasetPlaceholderEntry, + ParsedEntity as ParsedPartEntity, + RequestedOperation as Operation, + RoleAxis, + SentenceToken, + Shape, + Slot, + SlotOption, + SlotOptions, + SpecAlternativeMeasure, + SpecObservations, + TimeWindow, + UnitAlternative, + UnitConversion, + Warning, +} from '@developmentfinance/client/wire'; + +/** The envelope's contract version. */ +export type SchemaVersion = Present; + +/** The three outcomes a part with data to show can carry. */ +export type ResolveStatus = Exclude; + +/** The flat role a resolved entity plays: the subject role or a flow direction. */ +export type EntityRoleKey = wire.ResolvedEntity['role']; + +/** Who the data on a provenance comes from: the Observatory, Data Commons by Google, or the UN + * System Data Commons. */ +export type DataCommonsRef = wire.SearchModelsGrammarDataCommonsRef; + +/** The fixed slot-grammar axes. */ +export type SlotAxis = wire.SlotKey['axis']; + +/** Why a part offers a Spec as one of its candidates. */ +export type CandidateBasis = Present; + +/** Why a facet axis holds its value. */ +export type FacetBasis = wire.ResolvedFacet['basis']; + +/** What one party of a flow is beside the other parties on its side. */ +export type PartyKind = Present; + +/** A Spec's coverage, one of the exact, breadth and bare forms. */ +export type Coverage = wire.ResolvedSpec['coverage']; + +/** Why a definite Spec's observations were withheld; doubles as the paired warning's code. */ +export type ObservationsOmissionReason = wire.ObservationsOmission['reason']; -/** One narrowing term the request named, and whether a final slot binding - * consumed it. `unbound_qualifiers` exposes the entries that were not bound - * when a qualifier_not_bound outcome prevents a broader answer. */ -export interface ResolvedQualifier { - text: string; - named: GraphRef[]; - /** the constraint property of the slot_filters entry whose binding - * consumed the term, or, on an unbound value the answer's shape has a - * slot for but leaves unmet, that slot's property; null otherwise */ - slot?: string | null; - disposition: 'bound' | 'unbound' | 'no_vocabulary'; -} +/** What a question asked of a part's data beyond showing it. */ +export type OperationVerb = wire.RequestedOperation['verb']; -export interface ResolutionTrace { - resolved_entities: GraphRef[]; - resolved_sources: ProvenanceRef[]; - slot_filters: ResolvedFilter[]; - query_qualifiers?: ResolvedQualifier[]; - /** - * The date window originally requested (query intent); absent when no date - * was requested. - * The literal string "latest" means the query explicitly asked for the - * most-recent/current value — distinct from absent (no date signal at all). - * spec_id is anchored on the identity's own requested_window (an - * IdentityWindow, a distinct wire shape from this field) — see - * BaseIdentity.requested_window, which a dataset_resubmit identity entry - * round-trips to keep identity stable across promote. - */ - requested_window?: RequestedWindow; - /** - * The normalised form the quantity read found in the question when no - * wording pattern named one ("debt to GDP" asks for a rate), which the - * answer was then checked against. Null when a wording pattern named the - * form, when the question asks for none, and when no bind ran for this - * Spec. - */ - stated_form?: QuantityForm | null; -} +/** Why a question was declined. */ +export type DeclinedReason = wire.DeclinedAnswer['reason']; -// --------------------------------------------------------------------------- -// Slot / entity options (the taxonomy a slot was bound against) -// --------------------------------------------------------------------------- +/** Which candidate universe the options door enumerates for a place role. */ +export type PlaceOptionsScope = Present>; -/** One candidate value the binder had available for a slot. */ -export interface SlotOption { - /** one taxonomy member or observed facet value */ - value: SlotValue; - /** - * This option's observation footprint if bound instead of the current value — - * the "consequence before commit" count. Populated only when the request set - * include_option_coverage; null otherwise. - */ - coverage?: Coverage | null; - /** - * Where this option sits relative to its slot's bound value, for a - * level-scoped constraint-slot option on a code-index-carried taxonomy. - * Absent on every other surface this type serves: entity_options, - * facet_options, and the options door's OptionsSet. - */ - relation?: SlotOptionRelation | null; - /** - * How many StatVars this index holds coded directly to this option's - * value (not a subtree rollup), for a level-scoped constraint-slot - * option. Absent on every other surface this type serves: entity_options, - * facet_options, and the options door's OptionsSet. - */ - member_count?: number | null; - /** - * The flat dcid of this option's parent in its classification, on a - * level-scoped option that has one. With no value bound on a - * hierarchical slot, the options hold each root and its children, and a - * child names its root here. - */ - parent?: string | null; - /** - * The rows a download of this answer would write with this option bound - * instead of the current value, in the terms of `DownloadFacts.rows`; a - * bound slot is counted with that slot left unbound. A counted menu omits - * every option that would serve nothing, except the slot's current value. - * Null where the option is not counted yet: a subject-place family's - * bound slot gets its count in a trailing slot_options event. - */ - served_rows?: number | null; - /** false when served_rows is an upper bound; null exactly when - * served_rows is null */ - served_rows_exact?: boolean | null; - /** - * On an origin or destination entity option, what the party is beside the - * others on its side, as the code index lists it. Absent for a party the - * index does not list, and on every other option. - */ - party_kind?: PartyKind | null; -} +/** What an assumption fills: a declared default, an open flow role, or the place scope. */ +export type AssumptionKind = Known; -/** - * The full candidate set one slot was bound against. `key` joins to the matching - * `Spec.slots` entry on axis + property.ref (see SlotKey ref form "axis:dcid"). - */ -export interface SlotOptions { - key: SlotKey; - options: SlotOption[]; - /** - * False when options is the full known set for this slot at its - * current level: a curated seed taxonomy, a level-scoped taxonomy - * sourced from the packaged code index, or an observed taxonomy from - * the same source. True when options is only the values observed - * among the resolved candidates, bounded by retrieval/confirm caps. - */ - truncated: boolean; - source: SlotOptionsSource; -} +/** The block of the answer a sentence token belongs to. */ +export type SentenceSegment = Present; -/** - * The candidate set for a directional entity axis. An open axis has no bound - * entity_ref but still carries its candidates. - */ -export interface EntityOptions { - /** dcid of the bound directional entity; absent/null while the axis is open */ - entity_ref?: string | null; - role: RoleAxis; - options: SlotOption[]; - /** false when options is the full known candidate set (a curated seed) */ - truncated: boolean; - source: SlotOptionsSource; -} +/** Why a no_data outcome was returned. */ +export type NoDataReason = wire.NoData['reason']; -/** - * The candidate set one facet axis (provenance / measurementMethod / unit) - * was bound against, coverage-derived rather than a shape slot. The buffered - * body carries these inline on `Spec.facet_options`. The SSE path defers - * them to trailing `facet_options` events instead (the response's own array - * arrives empty), joined back onto the spec client-side by `spec_id` + - * `axis`. On `measurementMethod` and `unit`, an option whose `value` has no - * `ref` stands for the facets that declare no value on the axis; it appears - * only beside at least one declared value. - */ -export interface FacetOptions { - axis: string; - options: SlotOption[]; - truncated: boolean; - source?: SlotOptionsSource; -} +/** Which availability gate a no_data outcome's `flagged_match` or `alternate_match` failed. */ +export type NoDataGate = Present; // --------------------------------------------------------------------------- -// SpecObservations: the observation values behind a Spec's coverage +// Identity // --------------------------------------------------------------------------- -/** One (date, value) pair from a resolved StatVar's observation series. */ -export interface ObservationPoint { - /** the observation date, e.g. "2018" */ - date: string; - /** null when the graph reports a date with no value */ - value: number | null; -} +/** The identity a dataset_resubmit entry edits and posts back. */ +export type SpecIdentity = wire.BaseIdentity; -/** One axis's value on a single series' selected observation facet. - * `source`, `data_commons` and `documentation` ride the provenance axis - * only; the first two are absent for a provenance the origin catalogue does - * not list. */ -export interface SeriesFacetRef { - ref: string; - /** best-effort display label, null when unresolved */ - label?: string | null; - source?: SourceRef | null; - data_commons?: DataCommonsRef | null; - /** the Graph API route of the provenance's document */ - documentation?: string | null; -} +/** One what/how slot's binding state inside an identity. */ +export type IdentityBinding = wire.IdentitySlot['binding']; -/** A series' StatVar: its dcid and the row's own distinguishing label. */ -export interface SeriesStatVarRef { - ref: string; - label?: string | null; -} +/** The sparse facet map of an identity, keyed by facet axis. */ +export type IdentityFacets = Present; -/** One entity in a series' role tuple. */ -export interface SeriesEntityRef { - role: EntityRoleKey; - ref: string; - /** best-effort display label, null when unresolved */ - label?: string | null; - /** on an origin or destination entity, what the party is beside the - * others on its side, as the code index lists it. Absent for a party - * the index does not list, on observationAbout, and from an API that - * predates it. */ - party_kind?: PartyKind | null; -} - -/** One constraint of a series' StatVar: the property and the value the - * StatVar holds on it. The wire carries no property label, so a client names - * the property from the Spec's slots. */ -export interface SeriesAxisRef { - /** the constraint property's dcid, e.g. "crsPurpose" */ - property: string; - /** the dcid of the value the StatVar holds on `property` */ - ref: string; - /** display label for `ref`: its graph label, else the dcid's tail split - * into lower-case words */ - label: string; -} - -/** One entity a converted series substituted with its recipe's fallback entity. */ -export interface SeriesConversionFallbackExample { - entity: string; - fallback_entity: string; -} +/** The binding the site writes on each facet axis. Every facet carries a provenance, so + * `provenance` takes declared refs only; `measurementMethod` and `unit` also take a null ref, and + * `unit` alone takes the request form. The wire keys `IdentityFacets` by string and takes any of + * these on any axis. */ +export type IdentityFacetBinding = { + provenance: IdentityFacetRefBinding; + measurementMethod: IdentityFacetRefBinding | wire.IdentityFacetValueAbsent; + unit: IdentityFacetRefBinding | wire.IdentityFacetValueAbsent | wire.IdentityFacetValueRequest; +}; -/** The recipe and table versions behind a converted series' points. */ -export interface ObservationSeriesConversion { - recipe_id: string; - /** the Provenance node the exchange-rate and deflator tables loaded under */ - table_provenance: SeriesFacetRef; - exchange_rate_table_version: string; - /** absent for a current-prices conversion */ - deflator_table_version?: string | null; - fallback_count: number; - fallback_examples?: SeriesConversionFallbackExample[]; - projected_count: number; - /** capped, sorted 'entity@year' keys */ - projected_examples?: string[]; -} +/** The facet axes an identity binds. */ +export type IdentityFacetAxis = keyof IdentityFacetBinding; -/** - * One (stat_var, role tuple) pair's observation points, in chronological - * order (dfo-apis src/search/models/observations.py ObservationSeries). - */ -export interface ObservationSeries { - stat_var: SeriesStatVarRef; - /** the role tuple: one observationAbout entry for a single-role series, - * origin and destination for a bilateral dev-finance series */ - entities: SeriesEntityRef[]; - /** one entry per constraint of the StatVar, ordered by property dcid; - * empty for a StatVar with no constraints. Absent from an API that - * predates it, and from shares saved before it. */ - axes?: SeriesAxisRef[]; - /** the full series when SpecObservations.truncated is false, else the latest points only */ - points: ObservationPoint[]; - /** the selected facet's in-window point count, before truncation */ - total_obs: number; - /** provenance of the one observation facet these points were read from */ - source_refs: ProvenanceRef[]; - /** the selected facet per axis (provenance / measurementMethod / unit), - * sparse, and `provenance` agrees with `source_refs` */ - facet: Record; - /** absent on a series served in its native facet */ - conversion?: ObservationSeriesConversion | null; -} +/** A facet axis bound to declared values. */ +export type IdentityFacetRefBinding = wire.IdentityBindingValue | wire.IdentityBindingSet; -/** - * Why a definite Spec's observations were explicitly withheld, distinct from - * an ordinary empty result (dfo-apis src/search/models/observations.py's - * ObservationsOmission, paired with SpecObservations.omitted below). `reason` - * doubles as the code on the paired Warning the engine also emits (dfo-apis - * src/search/engine/region/codes.py), so `reason` and the warning code share - * one vocabulary. Some deployments don't send this field yet. A definite - * spec with no `omitted` and no `observations` event at all may simply - * predate it, not an assertion that the slice is empty. See view.ts's - * resolveObservationsOmission for the fallback this implies. - */ -export type ObservationsOmissionReason = - | 'observations_timed_out' - | 'observations_no_matching_source' - | 'observations_windowed_scope_unsupported' - | 'observations_converted_scope_unsupported' - | 'observations_no_dispatchable_facet' - | 'observations_bulk_fetch_failed' - | 'observations_probe_failed' - | 'observations_preview_too_large' - | 'observations_nothing_to_probe' - | 'observations_clients_unavailable' - | 'observations_no_points'; - -/** Set exactly when the preview was withheld: `series`, `total_obs`, and - * `truncated` on the enclosing SpecObservations then read `[]`, `0`, `false` - * respectively. `pairs`/`cap` are populated on `observations_preview_too_large` - * (the (stat_var, entity) pair count and the ceiling it exceeded). The rest - * are diagnostic bookkeeping for the fetch itself, meant for logs rather - * than display. */ -export interface ObservationsOmission { - reason: ObservationsOmissionReason; - message: string; - pairs?: number | null; - cap?: number | null; - dispatched?: number | null; - empty?: number | null; - hard_failed?: number | null; - facet_rejected?: number | null; -} +/** An identity's requested window: every year, the latest year, or a range. */ +export type IdentityWindow = wire.BaseIdentity['requested_window']; -/** - * The observation values behind a Spec's coverage: an always-on bounded preview, - * or the full series when the resolved answer is small enough to ship in full. - * Describes the SAME (stat_var, entity) selection Spec.coverage already reports. - */ -export interface SpecObservations { - /** one entry per (stat_var, entity) pair with observations */ - series: ObservationSeries[]; - /** total observation count across every series; known even when truncated */ - total_obs: number; - /** false when every series carries its full point set; true when each was truncated to its latest points */ - truncated: boolean; - /** why the preview was withheld, when it was; absent/null on an ordinary - * fetched result (whether or not it found points) and on an engine that - * doesn't send this field yet. */ - omitted?: ObservationsOmission | null; - /** how the preview's series were picked when it was cut to its cap: - * "ranked" (most recent and largest first) or "fixed"; absent/null when - * it was not cut, and on an engine that doesn't send this field yet */ - order?: 'ranked' | 'fixed' | null; -} +/** One dataset_resubmit entry. */ +export type DatasetResubmitEntry = wire.DatasetResubmitInput['entries'][number]; // --------------------------------------------------------------------------- -// Spec +// Page shapes (searchClient.normalizeResponse's output) // --------------------------------------------------------------------------- -/** Why a facet axis holds its value: bound by the request, completed from a - * declared default, or the engine's own most-in-window-points pick. */ -export type FacetBasis = 'bound' | 'declared_default' | 'fallback'; - /** - * One facet axis's resolved value on a Spec (dfo-apis src/search/models/ - * spec.py ResolvedFacet). Exactly one of `ref` and `selection` is present: - * `ref` (with `label`, and on the provenance axis `source`, `data_commons` - * and `documentation`) when one value served every carried series, - * `selection` when the series differ. + * A wire Spec flattened by `searchClient.flattenSpec`. `resolved` is spread flat, with `spec_id`, + * the trace again as `resolution`, the definition's `identity`, and the `definition` itself (the + * Datasets API mints unregistered specs from it). Specs nested in `equivalent_readings` stay in + * the wire form. */ -export interface ResolvedFacet { - basis: FacetBasis; - ref?: string | null; - label?: string | null; - source?: SourceRef | null; - data_commons?: DataCommonsRef | null; - /** provenance axis only: the Graph API route of the provenance's document */ - documentation?: string | null; - selection?: 'per_series' | null; - /** unit axis only, alongside `ref`: the ISO 4217 code ("USD", "KES") of - * the currency `ref` is denominated in; absent when `ref` is no - * currency */ - currency?: string | null; - /** unit axis only, when bound through the request form */ - request_basis?: Record | null; -} - -/** - * A Spec's hashed definition, minted by editing the elected Spec's identity - * onto an alternative measure's axes. No stat_vars, no coverage, no trace. - * Post its identity as a dataset_resubmit identity entry; the API resolves - * the membership from the identity. - */ -export interface SpecDefinition { - schema_version: SchemaVersion; +export type Spec = Omit & { spec_id: string; - identity: SpecIdentity; -} - -/** - * Another populated partition from the same measure family, or a distinct - * also_plausible candidate differing only in measured_property, folded in - * here instead of a second candidates entry. Mirrors dfo-apis - * SpecAlternativeMeasure. - */ -export interface SpecAlternativeMeasure { - definition: SpecDefinition; - /** readable text for this option, distinct from every other entry's on - * the same axis: a 'what' entry names the measure without its price - * basis ("net disbursements"), a 'unit' entry the prices it switches to - * ("constant prices") */ - label: string; - /** the token whose menu offers this entry: 'what', the measure noun, or - * 'unit', the byline's unit token (binding_ref "unit:"), for a - * partition that differs from the Spec's only in price basis */ - axis: 'what' | 'unit'; - /** the values this measure's partition holds, as Spec.measure_axis_values */ - measure_axis_values?: MeasureAxisValue[]; - /** the rows a download would write after a switch to this measure, in - * the terms of `DownloadFacts.rows`; null where counting needs graph - * reads */ - served_rows?: number | null; - /** false when served_rows is an upper bound; null exactly when - * served_rows is null */ - served_rows_exact?: boolean | null; -} - -/** One measure axis and the value a measure partition holds on it, e.g. - * nettingBasis = Gross. Mirrors dfo-apis MeasureAxisValue. */ -export interface MeasureAxisValue { - axis: string; - value: GraphRef; -} + resolution: wire.ResolutionTrace; + identity: SpecIdentity | null; + definition: wire.SpecDefinition | null; + facet_options?: FacetOptionsEntry[]; +}; -/** - * Another StatVar from the smallest StatVarGroup the Spec's one StatVar - * belongs to, with data at the Spec's places. Post `definition.identity` as - * a dataset_resubmit identity entry. Mirrors dfo-apis RelatedMeasure. - */ -export interface RelatedMeasure { - /** the sibling StatVar: its label names the row, its dcid the info icon */ - stat_var: GraphRef; - definition: SpecDefinition; -} +/** One facet axis's options as the page holds them. The stream's `facet_options` event sends no + * `source`. */ +export type FacetOptionsEntry = Omit; /** - * What a Datasets mint of the Spec's definition writes in its default file - * (no grouping), known before the first download. Mirrors dfo-apis - * DownloadFacts. + * A wire Spec `flattenSpec` could not read, because its `stat_vars` is not the wire's shape or it + * lacks the definition/resolved split. It carries its `spec_id` and whichever page Spec members the body + * held, so the page shows a failure state in its place. */ -export interface DownloadFacts { - /** the rows the definition's series serve, one per series and date, totals - * included. A long-layout CSV or XLSX keeps the rows where a role in the - * options document's `long_layout_totals` holds a total out of its main - * table, so that table can hold fewer. Null when the count could - * understate, as when a served member is missing from the coverage - * artifact. */ - rows?: number | null; - /** false when rows is an upper bound, or null */ - rows_exact: boolean; - /** distinct (StatVar, entities) with a point. Null when rows is null. */ - series?: number | null; - /** the first and last years of the file's points. Null when rows is null. */ - window?: TimeWindow | null; - /** the licence of the one provenance the answer serves. Null when it - * serves several or the catalogue names none. */ - licence?: { label: string } | null; -} +export type UnreadableSpec = Pick & + Partial> & { stat_vars_unreadable: true }; -/** - * Whether the elected Spec's served members agree on a single declared value - * for this axis or differ. Mirrors dfo-apis OpenConstraintAxisFact. - */ -export interface OpenConstraintAxisFact { - /** the constraint property dcid this fact is about */ - property: string; - varies: boolean; - /** the single value every declaring member agrees on; present only when - * varies is false and at least one member declares a value */ - value?: GraphRef | null; - /** how many distinct declared values the served members exhibit, not - * counting a member that declares nothing */ - distinct_count: number; - /** how many served members declare no value at all for this axis */ - undeclared_count: number; - /** every declared value with the served members declaring it, highest - * count first. The counts plus undeclared_count sum to the members. */ - values?: { value: GraphRef; count: number }[]; -} +/** A Spec as a part holds it: read, or flagged unreadable. `view.statVarsUnreadable` tells them + * apart. */ +export type PartSpec = Spec | UnreadableSpec; -export interface Spec { - /** - * Deterministic hash of (shape_id + canonical slot bindings). Permalink-safe; - * dedup key; golden-comparison handle; cache key. Same spec_id implies same - * Spec.entities GraphRef set. Does NOT depend on slot_options/entity_options. - */ - spec_id: string; - shape: Shape; - /** one entry per slot the shape defines, each in a binding state */ - slots: Slot[]; - /** - * The candidate set each bound slot was chosen from, one entry per slot with a - * non-empty taxonomy (omits when/source and empty slots). Rides on the response. - */ - slot_options?: SlotOptions[]; - /** the candidate set each bound directional entity was chosen from (dev-finance provider) */ - entity_options?: EntityOptions[]; - /** the candidate set each facet axis (provenance/measurementMethod/unit) was - * chosen from; empty on the SSE path until the trailing events merge in - * (see FacetOptions) */ - facet_options?: FacetOptions[]; - /** how many StatVars the Spec's family holds and serves */ - stat_vars: ResolvedStatVars; - /** resolved entities the data is about; may be empty if "where" is unbound */ - entities: ResolvedEntity[]; - /** the named group each role's entities stand for, keyed by role key. - * Null when no role was asked for as a group. Read it afresh after - * every response and post it unchanged beside any identity derived - * from this Spec (`DatasetIdentityEntry.entity_groups`); the client - * never edits a group. */ - entity_groups?: Record | null; - coverage: Coverage; - /** the facet axis values that backed this Spec's observations, sparse, - * and {} when observations were not resolved for this Spec */ - facets?: Record; - resolution: ResolutionTrace; - /** - * Whether this spec's representative stat var has observations at the resolved - * place. true=confirmed; false=matched but no data here (flagged_match); null=not - * assessed (no place, or a named family). - */ - available_at_place?: boolean | null; - /** qualitative, relative retrieval confidence; null when not assessed */ - confidence?: Confidence | null; - /** - * Why the part offers this Spec as one of its candidates. The elected - * reading, listed first, carries none; null on a definite answer and on - * every Spec outside CandidateSet.specs. - */ - basis?: CandidateBasis | null; - /** - * The observation values behind coverage above; absent when not resolved for this - * Spec (no entity to probe, options.include_observations is false, deferred to a - * trailing SSE `observations` event, or a candidates/nearest_real/flagged_match spec). - * Populated only on a primary definite answer. - */ - observations?: SpecObservations | null; - /** - * The identity a dataset_resubmit entry edits and posts back. Null only for a test fixture built without a definition/resolved - * split. - */ - identity?: SpecIdentity | null; - /** v1: the resolved Spec's own sentence (the envelope mirrors the primary one) */ - rendered_sentence?: string | null; - sentence_plan?: SentenceToken[] | null; - /** Null when this Spec's shape carries no measure family and no - * measure-sibling candidate was folded in; a served partition with - * every sibling unlabelled still publishes []. */ - alternative_measures?: SpecAlternativeMeasure[] | null; - /** Null under the same condition as alternative_measures. */ - measure_label?: string | null; - /** the measure-axis identifiers measure_label was composed from, so a - * bound how-slot sharing one doesn't repeat what measure_label already - * said; [] when measure_label is null */ - measure_label_axes?: string[]; - /** Null when the served members declare no such axis at all. */ - open_axis_facts?: OpenConstraintAxisFact[] | null; - /** each axis in measure_label_axes with the value the served partition - * holds on it, defaults included. [] when measure_label is null. */ - measure_axis_values?: MeasureAxisValue[]; - /** the StatVarGroup related_measures come from. Null when the list is - * null or empty. */ - related_group?: GraphRef | null; - /** up to 5 other members of related_group with data at the Spec's - * places. Null when not computed, [] when there are none. */ - related_measures?: RelatedMeasure[] | null; - /** what a download of this Spec's definition holds. Null when the Spec - * has no read plan or merges several answers. */ - download?: DownloadFacts | null; - /** the currencies and price bases this answer converts to. Null when - * its series cannot be converted. */ - unit_conversion?: UnitConversion | null; -} +/** A set of competing Specs, each flattened. */ +type CandidateSet = Omit & { specs: PartSpec[] }; -/** One currency a unit request can convert the answer to. Mirrors dfo-apis - * ConversionCurrency. */ -export interface ConversionCurrency { - /** the ISO 4217 code a request names the currency by */ - code: string; - /** the plural noun the sentence names it by ("euros") */ - noun: string; - /** the years a current-price request shows; null when none converts */ - current_years: TimeWindow | null; - /** the years a constant-price request shows; null when none converts */ - constant_years: TimeWindow | null; - /** the run of base years constant prices take; null when none */ - base_year_min: number | null; - base_year_max: number | null; -} - -/** - * What a convertible answer can switch its unit to, through a resubmit - * whose `identity.facets.unit` carries an IdentityUnitRequest. Mirrors - * dfo-apis UnitConversion. - */ -export interface UnitConversion { - /** each currency a unit request converts the answer to, by code */ - currencies: ConversionCurrency[]; - /** the base year a constant-price request takes when none is named */ - default_base_year: number; - /** the currency of the one entity each role names, keyed by role */ - role_currencies: Partial>; -} +/** A no_data outcome with its nearest, flagged and alternate Specs flattened. */ +export type NoData = Omit & { + nearest_real?: PartSpec[] | null; + flagged_match?: PartSpec | null; + alternate_match?: PartSpec | null; +}; -/** - * One what/how identity slot's binding state: the identity core's own wire - * shape, distinct from the engine's resolved `Binding` union - * (`Spec.slots[i].binding`, `{kind, value}`) that `lib/search/searchClient.ts`'s - * Slot/Binding mirror types describe. `state` (not `kind`) is the - * discriminator; `ref`/`refs` carry bare dcid strings, never a full - * `{ref,label}` GraphRef. - * - * What each state denotes: `value` denotes the bound code and every code - * under it via the code system's declared hierarchy — the code alone where - * the system is flat or the code is a leaf. `exact` denotes the code alone, - * even where the system's hierarchy would otherwise widen it; no engine - * mints this state yet (the datasets door exports it, and dfo-apis's search - * doors refuse a posted one with `exact_binding_unsupported`), so nothing in - * this codebase produces it, but a document carrying one type-checks. `set` - * denotes the union of its members' `value` denotations. `unbound` denotes - * any value or absence. - */ -export type IdentityBinding = - | { state: 'value'; ref: string } - | { state: 'exact'; ref: string } - | { state: 'set'; refs: string[] } - | { state: 'unbound' }; - -/** One what/how slot's identity contribution: bare property dcid + binding - * state. Never where/when/source — see BaseIdentity's field docs. */ -export interface IdentitySlot { - property: string; - binding: IdentityBinding; -} +/** The members `searchClient.flattenPart` defaults when the wire omits them. */ +type PartDefaults = { + restatement: wire.ParsedPart | null; + operations: wire.RequestedOperation[]; + warnings: wire.Warning[]; + departures: wire.Departure[]; +}; -/** The role form of a unit request's `currency`: the currency of the entity bound - * to that role, resolved per row. Mirrors dfo-apis IdentityCurrencyRole. */ -export type IdentityCurrencyRole = { role: EntityRoleKey }; - -/** A completed unit-conversion request — legal only inside a `unit`-axis - * facet binding's `request` form. */ -export interface IdentityUnitRequest { - /** an ISO 4217 code, or the currency of the one entity a role names */ - currency: string | { role: EntityRoleKey }; - prices: 'current' | 'constant'; - /** required exactly when prices === 'constant' */ - base_year?: number; -} +type PagePart = Omit & + PartDefaults & + TPayload; -/** A facet axis bound to declared values: the slot union minus `unbound` - * (a facet map is sparse — an unconstrained axis is simply absent, not - * `unbound`). */ -export type IdentityFacetRefBinding = - { state: 'value'; ref: string } | { state: 'set'; refs: string[] }; - -/** A declared-value binding, or `ref: null`: the facets that declare no - * value on the axis. The engine echoes the null back. */ -export type IdentityNullableFacetBinding = IdentityFacetRefBinding | { state: 'value'; ref: null }; - -/** A unit bound to the national currency of each entity a role names: the - * facets whose unit is that entity's own currency, served as published. */ -export interface IdentityNationalCurrencyBinding { - state: 'value'; - national_currency: { role: EntityRoleKey }; -} +type DefinitePart = PagePart; +export type CandidatesPart = PagePart; +export type NoDataPart = PagePart; +type UnsupportedPart = PagePart; -/** The sparse facet map over the identity core's three axes. Every facet - * carries a provenance, so `provenance` takes declared refs only; - * `measurementMethod` and `unit` also take a null ref, and `unit` alone - * takes the request and national-currency forms. */ -export interface IdentityFacets { - provenance?: IdentityFacetRefBinding; - measurementMethod?: IdentityNullableFacetBinding; - unit?: - | IdentityNullableFacetBinding - | { state: 'value'; request: IdentityUnitRequest } - | IdentityNationalCurrencyBinding; -} +/** A part whose status this release knows; its `status` narrows it. */ +type KnownPart = DefinitePart | CandidatesPart | NoDataPart | UnsupportedPart; -export type IdentityFacetAxis = keyof IdentityFacets; +/** A part whose status this release does not know. Its members pass through, with the defaults + * `flattenPart` gives every part. */ +type UnknownPart = wire.UnknownPart & PartDefaults; -/** The identity core's own `requested_window` union — distinct from the - * engine's resolved `RequestedWindow` (`TimeWindow | 'latest' | undefined`): - * `undefined` there maps to `{kind:'all'}` here, since a BaseIdentity always - * carries this member, never omits it. */ -export type IdentityWindow = - | { kind: 'all' } - | { kind: 'latest' } - | { kind: 'range'; start_year?: number; end_year?: number }; +/** One part of a resolution after normalisation. `isKnownPart` separates an unknown part before + * a switch on `status`. */ +export type Part = KnownPart | UnknownPart; -/** - * Contract v1 identity core (`BaseIdentity` on the wire): exactly what gets - * hashed into spec_id, serialised once and echoed back verbatim — never - * re-derived client-side. `shape` is opaque here (never constructed by this - * page, only copied or compared); `slots` covers only what/how axes — where - * lives in `entities` (keyed `observationAbout`/`origin`/`destination`), - * when in `requested_window`, source in `facets.provenance`. - */ -export interface BaseIdentity { - identity_version: 'spec1'; - shape: unknown; - slots: IdentitySlot[]; - entities: Record; - requested_window: IdentityWindow; - facets?: IdentityFacets; -} +const KNOWN_PART_STATUSES: ReadonlySet = new Set(PART_STATUSES); -/** A named group whose members fill one entity role ("European Union"). - * The members are the role's dcids in `BaseIdentity.entities`. With a - * `ref`, the API takes the label from the graph. Mirrors dfo-apis - * EntityGroup. */ -export interface EntityGroup { - /** the group's graph node; null when the graph holds none */ - ref: string | null; - label: string; - /** the graph type of the group's node (`Continent`, `UNGeoRegion`); - * null or absent when the group has no node */ - entity_type?: string | null; - /** the party the dataset publishes as the group's total, which the role - * binds in place of the members (`DACAfrica` for Africa); null or - * absent when the role binds the members */ - total?: string | null; +/** True for a part whose status this release knows, which then narrows on `status`. */ +export function isKnownPart(part: Part): part is KnownPart { + return KNOWN_PART_STATUSES.has(part.status); } -/** Alias for `Spec.identity`'s type; `BaseIdentity` is the canonical declaration. */ -export type SpecIdentity = BaseIdentity; - -/** - * Contract v1 wire Spec: `definition` (hashed identity) + `resolved` (everything - * the page shows). searchClient.flattenSpec spreads `resolved` into the flat - * Spec above (with `resolution` = resolved.trace, `identity` = definition.identity). - */ -export interface WireSpec { - spec_id: string; - definition: { schema_version: SchemaVersion; spec_id: string; identity: SpecIdentity }; - resolved: Omit & { trace: ResolutionTrace }; +/** The part as a known part; null for no part, or for one whose status this release does not + * know. */ +export function knownPart(part: Part | null | undefined): KnownPart | null { + return part && isKnownPart(part) ? part : null; } -// --------------------------------------------------------------------------- -// Sentence plan (the engine's rendered, tokenised confirmation sentence) -// --------------------------------------------------------------------------- +/** A resolution with its parts normalised. */ +export type Resolution = Omit & { parts: Part[] }; -/** The block of the answer a sentence token belongs to: a coverage token is - * a word of the "Answered 1 of 2 parts." a mixed answer's envelope sentence - * opens with, the heading states what the answer is, the byline its unit - * and source, a departure token carries the text of one `Departure` on the - * same part, and an assumption token is a word of the closing clause naming - * the part's `assumptions` ("Assumed: official development assistance; - * every provider."). */ -export type SentenceSegment = 'coverage' | 'heading' | 'byline' | 'departure' | 'assumption'; - -/** The axis a sentence token fills: every slot axis plus the byline's - * facet axes no request slot carries: 'unit', the unit of measure, and - * 'method', the measurement method (`binding_ref` "method:"), stated only - * while the identity binds it. */ -export type SentenceAxis = SlotAxis | 'unit' | 'method'; - -/** - * One token in the ordered sentence plan: connective text, or a bound phrase. - * `rendered_sentence` is the flat join of every token's `text`. A "slot" token - * with nothing bound still appears, with text="". Mirrors dfo-apis - * src/search/models/grammar.py SentenceToken. - */ -export interface SentenceToken { - /** 'text' is invariant wording (a connective, a separator, the terminal - * ".", or a departure sentence); 'slot' is a bound phrase */ - kind: 'text' | 'slot'; - /** rendered text for this token; empty string for a slot with nothing - * bound, or a how slot bound but silenced because the measure noun - * states it */ - text: string; - /** the block this token belongs to. A Spec's own plan holds heading and - * byline tokens only; departure and assumption tokens sit on a part's - * or the envelope's plan. */ - segment: SentenceSegment; - /** the grammatical axis this token fills, when kind==="slot" */ - axis?: SentenceAxis | null; - /** the constraint property dcid this token renders, when one exists */ - property_dcid?: string | null; - /** "origin"/"destination" when this token renders a directional entity role */ - role?: RoleAxis | null; - /** the binding this token edits: "origin:", "where:", "when:", "unit:", - * "method:", or "how:" (SlotKey ref form) */ - binding_ref?: string | null; - /** - * entity dcids this token renders, one per entity in the token's group (e.g. a - * multi-recipient "to" token carries every recipient's dcid, in the same order - * as the entities joined into `text`); absent/[] when this isn't a directional - * role token - */ - entity_refs?: string[]; - /** - * The refs this heading or byline slot token states, so the reader edits - * each from this token: its own binding_ref, then each binding the - * renderer left silent because this token's text already says it - * ("how:crsTypeOfFlow" on "gross ODA disbursements"), plus - * "axis:" for a measure axis no slot carries. Empty on a - * token with empty text. A binding no token states is offered by Refine. - * Absent on a wire that predates it. - */ - states?: string[]; - /** true on a candidate's token whose text differs from the same-bucket - * text on another candidate in the same set */ - distinguishes?: boolean | null; - /** - * Which part of a multi-variable envelope this token belongs to, when the - * plan is the envelope's own composed `sentence_plan` (ResolveResponse, - * not a single Spec's). Absent (never `null` on the wire) on a shared - * skeleton token: a connective, a hoisted qualifier, or the terminal ".". - * Compare with `== null`, not `=== null`. Always absent on a Spec's own - * sentence_plan, which describes one part only. - */ - part_index?: number | null; -} +/** Every resolve response body after normalisation, discriminated on `kind`. */ +export type ResolveResponse = Resolution | wire.CatalogueAnswer | wire.DeclinedAnswer; // --------------------------------------------------------------------------- -// Departures (how a part's answer differs from what its request asked) +// Stream payloads, hand-written because the OpenAPI document does not describe the SSE stream // --------------------------------------------------------------------------- -/** Every way an answer can differ from what its request asked, in the API's - * precedence order (dfo-apis src/search/models/vocab.py DepartureCode). */ -export type DepartureCode = - | 'closest_match' - | 'measure_not_applied' - | 'operation_not_applied' - | 'breakdown_not_applied' - | 'entity_unavailable' - | 'year_unavailable' - | 'modelled_values' - | 'group_read_as_members' - | 'direction_discarded' - | 'direction_inferred' - | 'scope_limited'; - -/** One way a part's answer differs from what its request asked. Mirrors - * dfo-apis src/search/models/departure.py Departure. */ -export interface Departure { - code: DepartureCode; - /** the binding whose edit clears this departure ("when:", "where:", - * "origin:", "unit:", "source:", "how:"); null when no - * binding edit clears it */ - binding_ref?: string | null; - /** what the request named, in its own wording */ - asked?: string | null; - /** what the answer shows instead, as the heading states it */ - shown?: string | null; - /** the departure as one or two short sentences for people */ - text: string; -} - -/** Departures the stream's `done` event adds to one part: facts that depend - * on the observations read, which arrive after `response`. A folded part and - * the part it folded into share a spec_id, so `part_index` names the part; - * `spec_id` confirms it still holds that part's spec. */ +/** Departures the stream's `done` event adds to one part. They are facts that depend on the + * observations read, which arrive after `response`. A folded part and the part it folded into + * share a spec_id, so `part_index` names the part and `spec_id` confirms it holds that part's + * spec. */ export interface LateDepartures { part_index: number; spec_id: string; - departures: Departure[]; -} - -/** What an `Assumption` fills (dfo-apis vocab.py AssumptionKind): - * 'declared_default', a slot the request left open that its code system's - * or shape's declared default fixed; 'open_role', a flow role no request - * word asked for, read across every entity on it with data, as one series - * per member where the role scope default filled it and the shape declares - * the role, else as the total over them; 'place_scope', a request that named no - * place, read across every place with data. */ -export type AssumptionKind = 'declared_default' | 'open_role' | 'place_scope'; - -/** One value a part's answer filled that its request did not ask for. - * Mirrors dfo-apis src/search/models/assumption.py Assumption. The sentence - * carrying the part closes with every one as one clause of `assumption` - * tokens. */ -export interface Assumption { - kind: AssumptionKind; - /** the binding whose edit replaces it: "how:" for a - * declared default, "origin:" or "where:" for an open role (the - * destination shares the subject's where slot), "where:" for the place - * scope */ - binding_ref: string; - /** the value dcids a declared default bound its slot to; [] for an - * open role and the place scope */ - refs: string[]; - /** the clause's words for it: "official development assistance", - * "every provider", "every country", "the world as a whole" */ - text: string; + departures: wire.Departure[]; } -// --------------------------------------------------------------------------- -// Frame (what one part asks; carried by the stream's `frame` event) -// --------------------------------------------------------------------------- -// Mirrors dfo-apis src/search/engine/frame.py. The response carries no frame; -// only the SSE stream's `frame` event does (searchClient.ts SearchEvent). - -/** Who stated a frame field: the caller, the extraction model, a code reader - * over the request's words, the quantity read's model over the variable - * phrase, or the default the question left in place. */ -export type FrameStatedBy = 'caller' | 'extraction' | 'reader' | 'model' | 'default'; +/** Who stated a frame field: the caller, the extraction model, a code reader over the request's + * words, the quantity read's model over the variable phrase, or the default the question left + * in place. */ +type FrameStatedBy = 'caller' | 'extraction' | 'reader' | 'model' | 'default'; /** A frame value and who stated it. */ -export interface FrameStated { +interface FrameStated { value: T; stated_by: FrameStatedBy; } -/** The flow axis a place is named on. `both` is a place put on both axes, as a - * self-flow; `undirected` is a place named with no axis. */ -export type FrameDirection = 'origin' | 'destination' | 'both' | 'undirected'; - -/** The reading asked of a place: its members each as their own place, or the - * default, which is the set's node where it has one and its members - * otherwise. */ -export type FrameReading = 'default' | 'members'; - -/** Every year the data holds, the latest year, or a window of years. */ -export type FrameWindow = 'all' | 'latest' | TimeWindow; - /** One place the part names, keyed by the request's text for it. */ -export interface FramePlace { - /** the request's text for the place; the dcid on the dataset_resubmit door */ +interface FramePlace { + /** the request's text for the place, or the dcid on the dataset_resubmit door */ name: string; stated_by: FrameStatedBy; - direction: FrameDirection; - /** whether the request's words give the place its direction; a role read - * off the measure, as in a possessive, states none */ + /** `both` is a place put on both axes, as a self-flow; `undirected` a place named with no + * axis */ + direction: 'origin' | 'destination' | 'both' | 'undirected'; + /** whether the request's words give the place its direction. A role read off the measure, + * as in a possessive, states none */ direction_stated: boolean; - reading: FrameReading; + /** its members each as their own place, or the default, which is the set's node where it + * has one and its members otherwise */ + reading: 'default' | 'members'; /** the dcid the caller pinned for the place, when it gave one */ pin?: string | null; - /** the places the name stands for under its reading, once resolved; empty - * before place resolution and for a name that did not resolve */ + /** the places the name stands for under its reading, once resolved; empty before place + * resolution and for a name that did not resolve */ dcids: string[]; } -/** One data source a part names or excludes, as its words, a reference, or - * both (dfo-apis src/search/models/request.py SourceMention). */ -export interface FrameSourceMention { +/** One data source a part names or excludes, as its words, a reference, or both. */ +interface FrameSourceMention { text?: string | null; ref?: string | null; polarity: 'include' | 'exclude'; } /** The codes the part's words match in one recalled vocabulary. */ -export interface FrameVocabularyCodes { +interface FrameVocabularyCodes { /** the vocabulary's constraint properties, sorted */ vocabulary: string[]; /** the code dcids matched, in match order */ @@ -1310,14 +316,14 @@ export interface FrameVocabularyCodes { } /** The value a constraint slot takes where the question names none. */ -export interface FrameSlotDefault { +interface FrameSlotDefault { property: string; value: string; stated_by: 'default'; } /** What the part measures, as the recalled vocabularies read its words. */ -export interface FrameMeasure { +interface FrameMeasure { codes: FrameStated; /** the measure class a word names (prevalence, incidence, mortality) */ measure_class?: FrameStated | null; @@ -1330,516 +336,48 @@ export interface FrameMeasure { } /** The form the part asks its measure in. */ -export interface FrameNormalisation { - /** the normalisations the words name (a QuantityForm each), sorted; "level" - * when they name none */ +interface FrameNormalisation { + /** the normalisations the words name, sorted; "level" when they name none */ form: FrameStated; - /** the denominator the words name, as the quantity read chose it: a base - * the eligible shapes divide by under its graph name, else the words that - * name it; null when the words name none */ + /** the denominator the words name, as the quantity read chose it, either a base the eligible + * shapes divide by under its graph name or else the words that name it; null when the + * words name none */ denominator?: FrameStated | null; } -/** Something the frame could not settle, with its possible readings. - * direction: a place named with no axis on a question a recalled shape gives - * roles; span: words that match two or more codes, or a code and a measure - * value; group_reading: a set holding both a node and members, read by - * default. */ -export interface FrameUnresolved { +/** Something the frame could not settle, with its possible readings. direction: a place named + * with no axis on a question a recalled shape gives roles; span: words that match two or more + * codes, or a code and a measure value; group_reading: a set holding both a node and members, + * read by default. */ +interface FrameUnresolved { kind: 'direction' | 'span' | 'group_reading'; /** the place name or the words */ subject: string; readings: string[]; } -/** What one part asks. `measure`, `normalisation` and `unresolved` are set - * after recall. The dataset_resubmit door builds no frame, so none reaches - * the page there. */ +/** What one part asks, carried by the stream's `frame` event. `measure`, `normalisation` and + * `unresolved` are set after recall. The dataset_resubmit door builds no frame. */ export interface Frame { variable: FrameStated; places: FramePlace[]; - window: FrameStated; + /** every year the data holds, the latest year, or a window of years */ + window: FrameStated<'all' | 'latest' | wire.TimeWindow>; /** the unit phrase the question states */ - unit?: FrameStated | null; + unit?: FrameStated | null; sources?: FrameStated | null; - operations: FrameStated; + operations: FrameStated; /** the flow axes asked member by member, sorted */ - open_roles: FrameStated; - /** the measurementQualifier the words name; no value stated by "default" - * reads the dataset's declared default reading. The stream omits a null - * value, so `value` is absent then. */ + open_roles: FrameStated; + /** the measurementQualifier the words name; no value stated by "default" reads the + * dataset's declared default reading. The stream omits a null value, so `value` is absent + * then. */ price_basis: { value?: string | null; stated_by: FrameStatedBy }; measure?: FrameMeasure | null; normalisation?: FrameNormalisation | null; unresolved: FrameUnresolved[]; } -// --------------------------------------------------------------------------- -// Envelope sub-objects -// --------------------------------------------------------------------------- - -/** A set of 2..N competing Specs, ordered per `ordering`. Each spec carries - * its own rendered_sentence/sentence_plan, lifted flat by flattenSpec. */ -export interface CandidateSet { - /** "intent_rank" on a first answer; "broadest_first" restates a posted - * candidates entry's own order */ - ordering: Ordering; - /** the maximum number of candidates applied */ - max_candidates: number; - /** 2..N competing Specs: the elected reading first, then its rivals, each - * stating its `basis` */ - specs: Spec[]; - truncated?: boolean; -} - -/** One graph node an ambiguous wording could name (dfo-apis - * src/search/models/outcomes.py AmbiguousEntityCandidate). */ -export interface AmbiguousEntityCandidate { - ref: string; - label: string; - /** country, continent, subregion, continental_union, organisation, - * aggregate, place_other, or other */ - kind: string; -} - -/** One query wording that resolved to more than one graph node, with the - * nodes it could name. `text` equals some `restatement.entities[i].text` - * on the same part. */ -export interface AmbiguousEntityGroup { - text: string; - candidates: AmbiguousEntityCandidate[]; -} - -/** One reading of an ambiguous source wording (dfo-apis - * src/search/models/outcomes.py AmbiguousSourceCandidate). */ -export interface AmbiguousSourceCandidate { - /** the source's reference; a client posts it back as a source mention's - * `ref` */ - ref: string; - /** the source's display name in the origin catalogue */ - label: string; - /** what `ref` names: a provenance, a catalogue Dataset, or a publishing - * Source */ - kind: 'provenance' | 'dataset' | 'source'; -} - -/** One source wording that names two or more unrelated admitted sources, with - * the sources it could name (dfo-apis src/search/models/outcomes.py - * AmbiguousSourceGroup). `text` equals some `restatement.sources[i].text` on - * the same part. */ -export interface AmbiguousSourceGroup { - text: string; - /** provenances first, then datasets and sources */ - candidates: AmbiguousSourceCandidate[]; -} - -/** The denominators a denominator_not_available refusal compared (dfo-apis - * src/search/models/outcomes.py NoDataDenominator). */ -export interface NoDataDenominator { - /** the denominator the question named, as read from the bases the - * candidate series divide by; null when it named none, or named one no - * candidate series divides by */ - asked?: GraphRef | null; - /** the denominator the flagged match divides by; null when it states none - * (a level, or a series whose name alone gives its base) */ - found?: GraphRef | null; -} - -/** One answer to the choice a no_data refusal asks for (dfo-apis - * src/search/models/outcomes.py NoDataOption). */ -export interface NoDataOption { - /** the request's own words this option settles, in query order */ - mentions: string[]; - /** the choice in words for a menu ("Republic of the Congo", "France to - * Germany"), for display only */ - label: string; - /** a one-part parsed input settling this choice only, posted unchanged - * to the resolve door; its one part is a `parsed` dataset_resubmit - * entry's part */ - data_query: ParsedInput; -} - -/** Outcome when no grounded result can be returned. */ -export interface NoData { - /** why nothing could be returned (closed enum) */ - reason: NoDataReason; - /** - * The entity wordings this refusal is about, in query order — populated - * for entity_not_resolved / entity_ambiguous / entity_role_not_admitted, - * empty for every other reason. The engine's own contract says to read - * this rather than parse `rendered_sentence` for the name. - */ - entity_terms?: string[]; - /** Every ambiguous wording behind an entity_ambiguous outcome, in query - * order, each with the nodes it could name. Empty for every other - * reason. */ - ambiguous_entities?: AmbiguousEntityGroup[]; - /** The source wordings this refusal is about, in query order: every - * included wording that matched no admitted source for - * source_not_resolved, every ambiguous wording for source_ambiguous, - * and the part's source_code for the three source_code_* reasons. - * Empty for every other reason. */ - source_terms?: string[]; - /** Every ambiguous wording behind a source_ambiguous outcome, in query - * order, each with the admitted sources it could name, and for - * source_code_ambiguous the part's source_code with the provenances - * holding it. Empty for every other reason. */ - ambiguous_sources?: AmbiguousSourceGroup[]; - /** - * One ready request per choice of an entity_ambiguous, source_ambiguous, - * source_code_ambiguous, role_not_resolved or unit_not_resolved refusal, - * in query then candidate order; empty for every other reason. It can - * list fewer entries than the candidates. The site's entity picker - * lists these options and posts the chosen one's part unchanged - * (parsedEdit.ts entityChoiceGroups). - */ - options?: NoDataOption[]; - /** Terms the engine refused to silently drop before answering. Present - * only for qualifier_not_bound. */ - unbound_qualifiers?: ResolvedQualifier[]; - /** optional; real, grounded specs with data adjacent to the request */ - nearest_real?: Spec[] | null; - gate?: NoDataGate | null; - /** - * optional; the single best-matched variable that exists but has no observation - * at the resolved place (available_at_place=false). Distinct from nearest_real: - * same variable + same place with no data here, vs. constraint-relaxed specs - * that DO have data nearby. - */ - flagged_match?: Spec | null; - /** - * Distinct from flagged_match (the elected variable, present on the - * other two gates) and from nearest_real (constraint-relaxed variants of - * the SAME variable). At most one of flagged_match/alternate_match is - * present at once. - */ - alternate_match?: Spec | null; - /** - * The currencies a unit_not_resolved refusal offers, in the binder's order. - * Present only with that reason and when the binder has candidates. - */ - unit_alternatives?: UnitAlternative[]; - /** - * The denominator the question named and the one the flagged match - * divides by. Set on every denominator_not_available refusal that - * elected a series; null for every other reason. - */ - denominator?: NoDataDenominator | null; -} - -/** A run-health signal. */ -export interface Warning { - /** stable machine code, e.g. DATE_COVERAGE_PARTIAL, COUNT_UNAVAILABLE */ - code: string; - severity: WarningSeverity; - message: string; - /** - * The spec_id this warning attaches to, when it concerns one returned - * spec specifically, and omitted otherwise. A warning with no spec_id - * has no part to attach it to (the per-spec signal it summarises - * arrives separately, through that spec's own events) and is dropped. - * Mirrors dfo-apis src/search/models/envelope.py's Warning.spec_id. - */ - spec_id?: string | null; - /** - * The counts and labels the message states. Three codes send it: - * observations_series_truncated {shown, total, order}, where order is - * "ranked" (most recent and largest first) or "fixed" and is absent - * from an API that predates it, elected_shape_no_data - * {no_data, elected}, whose values are lists of labels, and - * un_system_unavailable (UnSystemParams). - */ - params?: (Record & UnSystemParams) | null; -} - -/** An `un_system_unavailable` warning's params (dfo-apis - * src/search/engine/region/un_system.py un_unavailable_warning): the place - * dcids the UN System Data Commons refused, the StatVar dcids of the reads - * that failed, and those StatVars' labels in the same order, an empty - * string where the graph holds none. Each list is empty when the warning - * holds none. */ -export type UnSystemParams = { - places?: string[]; - variables?: string[]; - variable_labels?: string[]; -}; - -/** Latency breakdown for the response. */ -export interface Timing { - /** total run latency in ms */ - total: number; - /** per-step latency in ms */ - by_step?: Record | null; -} - -/** Run health, warnings, timing, and build stamp. */ -export interface Diagnostics { - /** engine build id; distinct from schema_version */ - engine_build: string; - /** this request's trace_id, shared with every problem-details body and - * the SSE stream's `done` event */ - trace_id: string; - /** degraded-mode, applied-signal, partial-graph signals */ - warnings: Warning[]; - timing_ms?: Timing | null; - /** per-request LLM token counts {input_tokens, output_tokens, cached_tokens} */ - llm_usage?: Record | null; - /** buffered transport only: soft deadline truncated this response (null on SSE, where the done event carries it) */ - timed_out?: boolean | null; -} - -export interface QueryEcho { - entry_path: 'raw_text' | 'parsed' | 'dataset_resubmit'; - /** the user's words, when one was supplied */ - raw_query?: string | null; - /** post-extraction normalised text, when extraction ran */ - normalized_query?: string | null; - /** true when the extraction step was skipped for this request */ - extract_skipped: boolean; -} - -// --------------------------------------------------------------------------- -// Part (contract v1): one entry per variable in ResolveResponse.parts -// --------------------------------------------------------------------------- -// The engine carries the payload — spec / candidates / no_data / unsupported, -// plus rendered_sentence / sentence_plan — inside a `parts[]` list, one entry -// per variable the query asked about, in query order. A single-variable query -// is one part. The page renders every part, in order (useSearchRun.ts's -// `HydratedPart[]`). WirePart is the shape a part carries on the wire (nested -// WireSpecs, un-flattened). -// searchClient.normalizeResponse maps each WirePart to the page-side `Part` -// below, whose specs are flattened by searchClient.flattenSpec. - -/** Wire-shaped candidate set carried on a candidates Part: `specs` are un-flattened - * WireSpecs (searchClient.flattenSpec turns these into CandidateSet.specs above). */ -export interface WireCandidateSet { - /** "intent_rank" on a first answer; "broadest_first" restates a posted - * candidates entry's own order */ - ordering: Ordering; - /** the maximum number of candidates applied */ - max_candidates: number; - /** 2..N competing WireSpecs, in `ordering` order */ - specs: WireSpec[]; - truncated?: boolean; -} - -/** Wire-shaped no_data outcome carried on a no_data Part. */ -export interface WireNoData { - reason: NoDataReason; - entity_terms?: string[]; - ambiguous_entities?: AmbiguousEntityGroup[]; - source_terms?: string[]; - ambiguous_sources?: AmbiguousSourceGroup[]; - options?: NoDataOption[]; - unbound_qualifiers?: ResolvedQualifier[]; - /** optional; real, grounded WireSpecs with data adjacent to the request */ - nearest_real?: WireSpec[] | null; - gate?: NoDataGate | null; - /** optional; the single best-matched variable that exists but has no observation - * at the resolved place (available_at_place=false) */ - flagged_match?: WireSpec | null; - alternate_match?: WireSpec | null; - /** - * The currencies a unit_not_resolved refusal offers, in the binder's order. - * Present only with that reason and when the binder has candidates. - */ - unit_alternatives?: UnitAlternative[]; - denominator?: NoDataDenominator | null; -} - -/** Outcome when the query itself can't be answered by this contract (e.g. an - * unsupported operation). The page shows `detail` in place of an empty - * heading. */ -export interface Unsupported { - code: string; - pointer?: string | null; - detail?: string | null; - definition?: unknown; -} - -/** What a question asked of a part's data beyond showing it. */ -export type OperationVerb = - 'rank' | 'aggregate' | 'growth' | 'share_of' | 'explain' | 'forecast' | 'report'; - -/** The axis an operation runs over. */ -export type OperationAxis = 'place' | 'origin' | 'destination' | 'category' | 'year'; - -/** - * One operation the question asked of a part that search did not apply, - * such as `rank` over `origin` for "top 5 donors". The part's data covers - * the whole axis; the website offers the operation to Studio. - */ -export interface Operation { - verb: OperationVerb; - /** the query's own wording for the operation, e.g. "top 5 donors" */ - phrase: string; - /** null when the verb runs over no axis, as `explain` does */ - over?: OperationAxis | null; - /** the four-digit year a `forecast` runs to, e.g. 2030 for "by 2030"; absent on other verbs */ - target_year?: number | null; -} - -/** - * One variable's complete outcome inside ResolveResponse.parts, as it rides - * the wire: its own status and exactly one of the four payload fields below, - * plus its own warnings/timing/sentence (not shared via the envelope). - * `spec`/`candidates`/`no_data` carry un-flattened WireSpecs — see `Part` - * for the page-side, flattened shape. - */ -export interface WirePart { - /** Every dataset_resubmit entry kind echoes its own variable_text onto - * the rebuilt part. Null is reserved for the raw_text path's - * empty-query refusal. */ - variable_text?: string | null; - /** this part's own signals only (e.g. binding_folded, observations_omitted) */ - warnings: Warning[]; - /** this part's own by_step timing; excludes the shared once-per-request extract step */ - timing_ms?: Timing | null; - /** a definite part stays null (the confirmation sentence lives at - * spec.resolved.rendered_sentence instead) */ - rendered_sentence?: string | null; - sentence_plan?: SentenceToken[] | null; - /** Null whenever this part was resolved from a posted identity/candidate - * set/stub (none carry a phrase of their own), or for another reason - * the wire does not name. */ - restatement?: ParsedPart | null; - /** operations the question asked of this part that search did not apply */ - operations?: Operation[]; - /** every way this part's answer differs from what its request asked, in - * DepartureCode order; [] when the answer is what was asked */ - departures: Departure[]; - /** every value the answer filled that the request did not ask for, in - * clause order: declared defaults, open roles, then the place scope; - * on a candidates part, the items every `reading_assumptions` list holds - * alike; [] on no_data and unsupported */ - assumptions?: Assumption[]; - status: ResolveStatus | 'unsupported'; - /** present iff status === "definite" */ - spec?: WireSpec; - /** present iff status === "candidates" */ - candidates?: WireCandidateSet; - /** candidates only: each shown reading's own assumptions, keyed by its - * Spec's spec_id, as a definite part answering that Spec would carry - * them; [] for a reading that assumes nothing. A pick echoes the picked - * reading's list on its identity entry, and a candidates entry echoes - * each reading's list on its candidate. */ - reading_assumptions?: Record; - /** present iff status === "no_data" */ - no_data?: WireNoData; - /** present iff status === "unsupported" */ - unsupported?: Unsupported; -} - -/** - * The page-side `Part`: same fields as `WirePart`, after - * searchClient.normalizeResponse has flattened every nested WireSpec (the - * definite `spec`, each of `candidates.specs`, and `no_data.nearest_real` / - * `no_data.flagged_match`) into the flat `Spec` shape above. - */ -export interface Part { - variable_text?: string | null; - /** this part's own signals only (e.g. binding_folded, observations_omitted) */ - warnings: Warning[]; - /** this part's own by_step timing; excludes the shared once-per-request extract step */ - timing_ms?: Timing | null; - /** a definite part stays null (the confirmation sentence lives at - * spec.rendered_sentence instead) */ - rendered_sentence?: string | null; - sentence_plan?: SentenceToken[] | null; - restatement: ParsedPart | null; - /** operations the question asked of this part that search did not - * apply; normalizeResponse defaults it to empty when the wire omits it */ - operations: Operation[]; - /** every way this part's answer differs from what its request asked, in - * DepartureCode order; [] when the answer is what was asked. The stream's - * `done` event can add more (see searchClient.applyDoneEvent). */ - departures: Departure[]; - /** every value the answer filled that the request did not ask for, in - * clause order (see WirePart.assumptions) */ - assumptions?: Assumption[]; - status: ResolveStatus | 'unsupported'; - /** present iff status === "definite" */ - spec?: Spec; - /** present iff status === "candidates" */ - candidates?: CandidateSet; - /** candidates only: each shown reading's own assumptions, keyed by - * spec_id (see WirePart.reading_assumptions) */ - reading_assumptions?: Record; - /** present iff status === "no_data" */ - no_data?: NoData; - /** present iff status === "unsupported" */ - unsupported?: Unsupported; -} - -// --------------------------------------------------------------------------- -// ResolveResponse (the envelope) -// --------------------------------------------------------------------------- - -/** The fields every response body carries, whatever its `kind`. */ -interface ResponseBody { - schema_version: SchemaVersion; - query_echo: QueryEcho; - diagnostics: Diagnostics; -} - -/** - * A resolution: one entry in `parts` per variable the query asked about, in - * query order — a single-variable query is one part. Each part carries its - * own outcome (`status` plus exactly one of `spec` / `candidates` / - * `no_data` / `unsupported`). searchClient.normalizeResponse flattens the - * nested WireSpecs in `parts`. The page renders every part, in order - * (useSearchRun.ts's `HydratedPart[]`). - */ -export interface Resolution extends ResponseBody { - kind: 'resolution'; - /** the parts' shared status, or "mixed" when two or more parts disagree. - * Includes "unsupported", a Part's own status literal distinct from - * ResolveStatus: an all-unsupported envelope is itself a valid shared - * value, separate from mixed. */ - status: ResolveStatus | 'unsupported' | 'mixed'; - /** one entry per variable asked, in query order; always >= 1 */ - parts: Part[]; - /** - * The envelope's own composed sentence over every part in `parts`: one - * hero sentence rather than N per-part ones. A single-part response's - * plan equals that part's own plan. See SentenceToken.part_index - * and `lib/search/sentencePlan.ts`'s tokenPart for how a multi-part - * plan's tokens route to their part. - */ - rendered_sentence?: string | null; - sentence_plan?: SentenceToken[] | null; -} - -/** The country a catalogue or declined answer is about, when the query named - * one and the resolver confirmed it. */ -export interface Scope { - place: GraphRef | null; -} - -/** A question about what data exists, such as "What data do you have on - * Kenya?". It carries no parts; the website renders the catalogue. */ -export interface CatalogueAnswer extends ResponseBody { - kind: 'catalogue'; - scope: Scope; -} - -/** Why a question was declined: it asks for something outside the data - * (`out_of_scope`), or for analysis without naming data to show - * (`requires_analysis`). */ -export type DeclinedReason = 'out_of_scope' | 'requires_analysis'; - -/** A question search does not answer. It carries no parts and no message; - * the website writes the copy. */ -export interface DeclinedAnswer extends ResponseBody { - kind: 'declined'; - reason: DeclinedReason; - scope: Scope; -} - -/** Every resolve response body, discriminated on `kind`. Every outcome is - * HTTP 200; only a resolution carries parts and a `status`. */ -export type ResolveResponse = Resolution | CatalogueAnswer | DeclinedAnswer; - // --------------------------------------------------------------------------- // Per-part run state (client-side only; never on the wire). One resolve run // holds N parts in query order, each streaming, editing, and erroring on its @@ -1869,7 +407,7 @@ export interface HydratedPart { part: Part | null; /** `part.spec` with its trailing observations/facet_options merged in; * populated only when `part.status === 'definite'`. */ - hydratedSpec: Spec | null; + hydratedSpec: PartSpec | null; /** True while this part's hydrated spec exists, its `observations` is * still null, and one of the run's streams is open (the base run is * loading, or an edit post is in flight): "the observations have not @@ -1885,6 +423,14 @@ export interface HydratedPart { editError: string | null; } +/** A definite part whose spec has landed. */ +export type DefiniteHydratedPart = HydratedPart & { hydratedSpec: PartSpec }; + +/** True for a definite part whose spec has landed. */ +export function hasDefiniteSpec(part: HydratedPart): part is DefiniteHydratedPart { + return part.part?.status === 'definite' && part.hydratedSpec != null; +} + /** The named skeleton list a run seeds from the `interpretation` event, * before any part has its own content, sized to `expected_results`, in * query order. Reset (and re-derived from the confirmed `parts`) once the @@ -1893,231 +439,3 @@ export interface ExpectedPart { index: number; variableText: string; } - -// --------------------------------------------------------------------------- -// Request -// --------------------------------------------------------------------------- - -/** path A: UI, Python package. Extract runs. */ -export interface RawTextInput { - kind: 'raw_text'; - query: string; -} - -/** path B: pre-parsed parts, extract skipped. Also the shape of - * `NoDataOption.data_query`. Parts byte-identical in every field collapse to - * one before resolution. */ -export interface ParsedInput { - kind: 'parsed'; - /** 1 to 6, one per variable ask */ - parts: ParsedPart[]; - /** echo-only; the engine MUST NOT re-parse it */ - raw_query?: string | null; -} - -/** - * `role_text` is the display role when the query bound a directional role, - * absent for a plain subject entity. `dcid` is a caller-supplied pin, only - * ever sent on a posted entry, only when the response being edited carried - * `ambiguous_entities`. - */ -export interface ParsedPartEntity { - text: string; - role_text?: string | null; - /** a pinned graph node for `text`: name resolution is skipped and the - * dcid confirmed against the graph instead */ - dcid?: string | null; - /** true when the entity names a group whose member places fill the role */ - members?: boolean | null; -} - -/** - * The canonical restatement of one part's inputs: what the engine actually - * resolved from, not the user's own wording. Posting it back (a `parsed` - * dataset_resubmit entry) reproduces the same DateRequest, entities, unit, - * source, and spec_id. `time_text` is absent for an open-start or reversed - * window, where no phrase exists for it. Never shown to the user as "what - * you asked". - */ -export interface ParsedPart { - variable_text: string; - /** null, omitted or empty all mean no entity was named for this part */ - entities?: ParsedPartEntity[] | null; - time_text?: string | null; - unit_text?: string | null; - source_text?: string | null; - /** echoed onto the resolved part */ - operations?: Operation[]; -} - -// --------------------------------------------------------------------------- -// dataset_resubmit: the multi-part edit door (contract v1). Four entry kinds -// are modeled here, keyed by `entry_kind`. `buildDatasetEntries` -// (datasetEntries.ts) never derives a `parsed` entry from a part's own -// status; a typed edit or an entity_ambiguous pick builds one directly and -// passes it in as an override. Every entry carries its part's `operations` -// back, and the engine echoes them onto the rebuilt part. An identity entry -// also carries its part's `assumptions` (a picked reading's own list on a -// candidates part), and each candidate its reading's. -// --------------------------------------------------------------------------- - -/** A definite part's entry: the complete identity the part should resolve - * to, edited or not. Mirrors `ResubmitIdentityPayload` (dfo-apis - * src/search/models/request.py), which derives the membership from the - * identity alone. Every entry carries `variable_text`: the engine nulls it - * otherwise and the composer would render a blank phrase. `entity_groups` - * is the `Spec.entity_groups` of the Spec the identity derives from, keyed - * by role key; the engine keeps a group whose role the identity still - * binds to the group's members and drops the rest. */ -export interface DatasetIdentityEntry { - entry_kind: 'identity'; - variable_text: string; - identity: BaseIdentity; - entity_groups?: Record | null; - operations?: Operation[]; - /** the part's `assumptions`, echoed as the response gave them, [] included. - * The engine states the echoed items that still hold after the edit and - * nothing the echo lacks, so [] states none. When absent, it computes - * them as for a fresh request. */ - assumptions?: Assumption[]; -} - -/** One competing identity inside a `candidates` entry, with the - * `Spec.entity_groups` of the candidate Spec it came from. Specs return in - * posted order. */ -export interface DatasetCandidateIdentity { - identity: BaseIdentity; - entity_groups?: Record | null; - /** the reading's `reading_assumptions` list, echoed as the response gave - * it, [] included, by the rule `DatasetIdentityEntry.assumptions` states. - * When absent, the engine computes the reading's assumptions as for a - * fresh request. */ - assumptions?: Assumption[]; -} - -/** A candidates part's entry: `ordering`/`truncated` echoed verbatim from the - * part's own CandidateSet, `candidates` at least 2. No `max_candidates`: the - * door defines the entry without it and rejects the extra field. A - * candidate that promotes to no_data drops. Fewer than 2 survivors return - * definite (1) or no_data (0) in the response. */ -export interface DatasetCandidatesEntry { - entry_kind: 'candidates'; - variable_text: string; - ordering: Ordering; - truncated: boolean; - candidates: DatasetCandidateIdentity[]; - operations?: Operation[]; -} - -/** A no_data or unsupported part's entry: holds the part's position in the - * request without asking the engine to re-derive anything. The response - * part for this entry carries `part_restated`. The client keeps its own - * richer copy of that part rather than trusting the returned stub. */ -export interface DatasetPlaceholderEntry { - entry_kind: 'placeholder'; - variable_text: string; - status: 'no_data' | 'unsupported'; - reason?: NoDataReason; - code?: string; - operations?: Operation[]; -} - -/** - * Re-parsed, where `placeholder` only restates a stub. `part` already - * carries its own required `variable_text`, so this entry needs no separate - * top-level echo, and its operations ride on `part`. Mirrors dfo-apis - * `ResubmitParsedEntry`. - */ -export interface DatasetParsedEntry { - entry_kind: 'parsed'; - part: ParsedPart; -} - -export type DatasetResubmitEntry = - DatasetIdentityEntry | DatasetCandidatesEntry | DatasetPlaceholderEntry | DatasetParsedEntry; - -/** - * path D: the multi-part edit door. `entries` are 1 to 6, in response - * part order. `raw_query` is echo-only, same as ParsedInput's. - */ -export interface DatasetResubmitInput { - kind: 'dataset_resubmit'; - entries: DatasetResubmitEntry[]; - raw_query?: string; -} - -export type ResolveInput = RawTextInput | ParsedInput | DatasetResubmitInput; - -export interface ResolveOptions { - coverage?: 'auto' | 'prefer_exact' | 'breadth_only' | 'none'; - place_as_constraint?: boolean; - /** when true, each SlotOption.coverage carries an observation count */ - include_option_coverage?: boolean; - /** - * When true (the default -- absent also means true), Spec.observations is - * populated with the resolved answer's observation values. Set false to opt - * out. Delivery mirrors include_option_coverage's SSE/buffered transport split. - */ - include_observations?: boolean; -} - -export interface ResolveRequest { - schema_version: SchemaVersion; - input: ResolveInput; - options?: ResolveOptions; -} - -// --------------------------------------------------------------------------- -// EntityOptionsWire: on-demand entity-slot alternatives (POST /api/search/options), -// place_options' successor (see searchClient.ts's entityOptions for the door itself). -// --------------------------------------------------------------------------- - -/** Which anchor types each scope supports in v1 is documented on the engine's - * options endpoint (app.py); an unsupported anchor type is a typed 400, - * not a silent empty result. */ -export type PlaceOptionsScope = 'peers' | 'children'; - -/** - * Request body for the options door. Lazy and on-demand: fetched only when a - * user opens an entity editor, not part of /resolve. The door derives the - * identity's own StatVar population server-side, so the client posts no - * resolved-stat-var seed, just the spec's hashed identity and - * which role/scope it wants alternatives for. `for.role` names which entity - * role the returned options fill: the bound anchor's own role, e.g. - * 'destination' for a CRS recipient, defaulting to 'observationAbout' for a - * non-directional place. - */ -export interface EntityOptionsWireRequest { - /** the spec's hashed identity, opaque to the client */ - identity: unknown; - for: { role: EntityRoleKey; scope: PlaceOptionsScope }; - /** per-option data footprint (observation counts / years) for the rows */ - include_coverage: boolean; -} - -/** One candidate entity confirmed to have data, with a resolved display label. */ -export interface EntityOptionsWireValue { - ref: GraphRef; - value_kind: 'entity'; -} - -/** Response for POST /api/search/options. */ -export interface EntityOptionsWireResponse { - options: { - /** candidate entities confirmed to have data, in candidate order */ - items: Array<{ - value: EntityOptionsWireValue; - coverage?: Coverage | null; - /** on an origin or destination entity option, what the party is - * beside the others on its side, as the code index lists it. - * Absent for a party the index does not list. */ - party_kind?: PartyKind | null; - }>; - /** - * true when items is only a subset of the full candidate set (the - * endpoint's own cap), false when items is the complete candidate - * set -- NOT a statement about how many candidates had data. - */ - truncated: boolean; - }; -} diff --git a/src/lib/search/unitLabel.ts b/src/lib/search/unitLabel.ts index 3a7811f..5095640 100644 --- a/src/lib/search/unitLabel.ts +++ b/src/lib/search/unitLabel.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- reads the shallow Spec the sentence menus read (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- reads specs as `any`, dereferencing only the members it names and tolerating any that is missing */ /** * The unit the way the byline names it, with the price basis ("Constant 2020 * US dollars", "Current euros"). The Spec's unit facet names the currency diff --git a/src/lib/search/unitPicker.ts b/src/lib/search/unitPicker.ts index b7ac662..aa93f44 100644 --- a/src/lib/search/unitPicker.ts +++ b/src/lib/search/unitPicker.ts @@ -131,8 +131,8 @@ export const sharedOffer = (offers: UnitConversion[]): UnitConversion | null => (currency) => sharedCurrency(currency, others) ?? [], ); const roleCurrencies = Object.fromEntries( - Object.entries(first.role_currencies).filter(([role, code]) => - others.every((offer) => offer.role_currencies[role as EntityRoleKey] === code), + Object.entries(first.role_currencies ?? {}).filter(([role, code]) => + others.every((offer) => offer.role_currencies?.[role] === code), ), ); @@ -150,7 +150,7 @@ const requestCode = ( if (!currency) return null; if (typeof currency === 'string') return currency; - return conversion?.role_currencies[currency.role] ?? null; + return conversion?.role_currencies?.[currency.role] ?? null; }; /** The answer's unit: the identity's unit request when it carries one, @@ -191,7 +191,7 @@ export const unitPresets = (spec: any, conversion: UnitConversion | null): UnitP })); const entities: any[] = spec?.entities ?? []; - for (const [role, code] of Object.entries(conversion.role_currencies) as [ + for (const [role, code] of Object.entries(conversion.role_currencies ?? {}) as [ EntityRoleKey, string | undefined, ][]) { diff --git a/src/lib/search/versionLink.ts b/src/lib/search/versionLink.ts index c60915e..9e1ff1f 100644 --- a/src/lib/search/versionLink.ts +++ b/src/lib/search/versionLink.ts @@ -14,6 +14,13 @@ * it directly. */ +import type { + ArtifactEntryModel, + BaseIdentity, + Manifest, + OptionsEnvelope, +} from '@developmentfinance/client/wire'; + import type { OpenedSearchShare } from './searchClient'; export type VersionLink = { specId: string; contentVersion: string | null }; @@ -42,18 +49,10 @@ export const parseVersionLink = (params: { export const versionLinkHref = ({ specId, contentVersion }: VersionLink): string => contentVersion ? `/data?spec=${specId}&v=${contentVersion}` : `/data?spec=${specId}`; -/** The fields of a version manifest or options envelope this module reads. */ -export type VersionLinkDocument = { - content_version?: string; - definition?: { spec_id?: string; identity?: unknown } | null; - artifacts?: { - artifact_digest?: string; - created_at?: string; - url?: string; - rendering?: { format?: string; bundle?: boolean }; - }[]; - citation?: { sources?: { label?: string }[] }; -}; +/** The fields of a version manifest or options envelope this module reads; the envelope lists no + * artifacts and cites no sources. */ +export type VersionLinkDocument = Pick & + Partial>; /** A version's stored file, by the site route that redirects to its stable * link; `format` is 'zip' for the bundle. */ @@ -68,19 +67,17 @@ export type VersionLinkOutcome = // A definition that pins its StatVars (Datasets' statvar_set and // source_codes inputs) names no measure family, and Search refuses to // resubmit it. -const isPinnedKind = (identity: unknown): boolean => - (identity as { shape?: { kind?: unknown } }).shape?.kind === 'stat_var'; +const isPinnedKind = (identity: BaseIdentity): boolean => identity.shape.kind === 'stat_var'; -type StoredFile = { - url: string; - artifact_digest?: string; - rendering: { format: string; bundle?: boolean }; -}; +/** The format a listed file was rendered in, or null for a rendering with none, such as the + * series statistics. */ +const formatOf = ({ rendering }: Pick): string | null => + typeof rendering.format === 'string' && rendering.format ? rendering.format : null; /** The canonical frame a version manifest lists: the API's internal storage * form, which the site offers in no download format. */ -export const isCanonicalFrame = (artifact: { rendering?: { format?: string } }): boolean => - artifact.rendering?.format === 'frame'; +export const isCanonicalFrame = (artifact: Pick): boolean => + formatOf(artifact) === 'frame'; /** * The version's file to offer, by its stable link, with its digest for a pin: @@ -89,21 +86,27 @@ export const isCanonicalFrame = (artifact: { rendering?: { format?: string } }): * offered. `/api/version-download` and `/s//download` redirect to it. */ export const storedDownload = (document: { - artifacts?: VersionLinkDocument['artifacts']; -}): { url: string; format: string; digest: string | null } | null => { - const files = (document.artifacts ?? []).filter( - (artifact): artifact is StoredFile => - !!artifact.url && !!artifact.rendering?.format && !isCanonicalFrame(artifact), - ); - const bundle = files.find((artifact) => artifact.rendering.bundle); + artifacts?: ArtifactEntryModel[]; +}): { url: string; format: string; digest: string } | null => { + const files = (document.artifacts ?? []).flatMap((artifact) => { + const format = formatOf(artifact); + + return artifact.url && format && !isCanonicalFrame(artifact) + ? [ + { + url: artifact.url, + format, + digest: artifact.artifact_digest, + bundle: artifact.rendering.bundle === true, + }, + ] + : []; + }); + const bundle = files.find((file) => file.bundle); const file = bundle ?? files[0]; return file - ? { - url: file.url, - format: bundle ? 'zip' : file.rendering.format, - digest: file.artifact_digest ?? null, - } + ? { url: file.url, format: bundle ? 'zip' : file.format, digest: file.digest } : null; }; @@ -125,7 +128,8 @@ export const outcomeFromDocument = ( link: VersionLink, document: VersionLinkDocument, ): VersionLinkOutcome | null => { - const identity = document.definition?.identity; + // The wire types the identity as an open object; the definition holds a BaseIdentity. + const identity = document.definition?.identity as BaseIdentity | undefined; if (!identity) { return null; @@ -144,10 +148,10 @@ export const outcomeFromDocument = ( .map((artifact) => artifact.created_at) .filter((at): at is string => typeof at === 'string') .toSorted()[0]; - const specId = document.definition?.spec_id ?? link.specId; + const specId = document.definition.spec_id; const sources = (document.citation?.sources ?? []) .map((source) => source.label) - .filter((label): label is string => !!label); + .filter((label) => label !== ''); const shared: OpenedSearchShare = { id: specId, diff --git a/src/lib/search/view.ts b/src/lib/search/view.ts index f5a12d7..ae2b47b 100644 --- a/src/lib/search/view.ts +++ b/src/lib/search/view.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- deliberately shallow engine-contract layer (see lib/search/types.ts for the full mirror) */ +/* eslint-disable @typescript-eslint/no-explicit-any -- reads specs and options of either picker shape as `any`, each helper dereferencing only the members it names and tolerating any that is missing */ /** * View helpers: flatten a resolved Spec into UI-ready rows and summaries. * Deliberately defensive — the engine contract is authoritative, the UI reads @@ -16,39 +16,51 @@ import type { EntityChoiceGroup } from './parsedEdit.ts'; import { entityOptionDcid } from './pickerState.ts'; import type { OptionLike } from './pickerState.ts'; import type { ApiError, EntityOption } from './searchClient'; -import type { - Assumption, - EntityOptions, - EntityRoleKey, - FacetBasis, - IdentityFacets, - NoData, - NoDataDenominator, - NoDataGate, - NoDataReason, - ObservationsOmission, - ObservationsOmissionReason, - ParsedPart, - Part, - PartyKind, - PlaceOptionsScope, - RoleAxis, - SentenceToken, - UnitAlternative, - Warning, -} from './types'; +import { + type Assumption, + type EntityOptions, + type EntityRoleKey, + type FacetBasis, + type ObservationSeries, + type GraphRef, + hasKey, + type IdentityFacetBinding, + type Known, + type NoData, + type NoDataDenominator, + type NoDataGate, + type NoDataReason, + type ObservationsOmission, + type ObservationsOmissionReason, + type ParsedPart, + type Part, + type PartSpec, + type PartyKind, + type PlaceOptionsScope, + type RoleAxis, + type SentenceToken, + type Slot, + type SlotOption, + type Spec, + type UnitAlternative, + type UnreadableSpec, + type Warning, +} from './types.ts'; import { keyRef, sentenceOf } from './searchClient.ts'; import { unitWithBasis } from './unitLabel.ts'; /** Display label of a slot/entity option, wherever the engine put it. */ -export const optionLabel = (option: any): string => +export const optionLabel = (option: OptionLike | null | undefined): string => option?.value?.ref?.label ?? option?.label ?? '—'; -/** True when searchClient.flattenSpec couldn't read this spec's `stat_vars` - * as the wire's ResolvedStatVars object shape — it already logged the raw - * value to the console at parse time. A card reading this spec's variable - * label should show an explicit failure state rather than an empty one. */ -export const statVarsUnreadable = (spec: any): boolean => !!spec?.stat_vars_unreadable; +/** True when searchClient.flattenSpec could not read this spec, because its `stat_vars` + * is not the wire's ResolvedStatVars object shape or it lacks the + * definition/resolved split, and logged the raw value to the + * console at parse time. A card reading this spec's variable label should + * show a failure state. */ +export function statVarsUnreadable(spec: PartSpec | null | undefined): spec is UnreadableSpec { + return !!spec && 'stat_vars_unreadable' in spec; +} /** The resolved spec's measure, named for a card whose part carries no * variable_text: its measure label, else its shape's label. Null when @@ -67,17 +79,20 @@ export const cardText = (spec: any, fallbackText?: string | null): string | unde * ref, in the engine's own rank order (in-window observation count). An * option with no ref (measurementMethod and unit only) stands for the * facets that declare no value on the axis, and is kept once. */ -const facetAxisOptions = (spec: any, axis: string): any[] => { +const facetAxisOptions = ( + spec: Pick | null | undefined, + axis: string, +): SlotOption[] => { const seen = new Set(); - const out: any[] = []; + const out: SlotOption[] = []; (spec?.facet_options ?? []) - .filter((entry: any) => entry?.axis === axis) - .forEach((entry: any) => { - (entry.options ?? []).forEach((option: any) => { - const value = option?.value; + .filter((entry) => entry.axis === axis) + .forEach((entry) => { + entry.options.forEach((option) => { + const { value } = option; - if (!value || (value.ref && !value.ref.ref)) { + if (value.ref && !value.ref.ref) { return; } @@ -102,16 +117,20 @@ const facetAxisOptions = (spec: any, axis: string): any[] => { * such a ref comes back definite with the sentence gaining "according to …". * Reshaped to the slot-option contract so the pickers treat them uniformly. */ -export const sourceSlotOptions = (spec: any): any[] => +export const sourceSlotOptions = (spec: any): SourceSlotOption[] => facetAxisOptions(spec, 'provenance').filter( - (option) => option.value.ref && option.value.value_kind === 'source', + (option): option is SourceSlotOption => + !!option.value.ref && option.value.value_kind === 'source', ); +/** A source slot's option: a provenance, so its value always carries a ref. */ +export type SourceSlotOption = SlotOption & { value: { ref: GraphRef } }; + /** * Provenance labels arrive dcid-squished ("WorldDevelopmentIndicators") while * the engine's own rendered sentence spaces them out — mirror that spacing. */ -export const sourceOptionLabel = (option: any): string => +export const sourceOptionLabel = (option: OptionLike | null | undefined): string => optionLabel(option) .replace(/_/g, ' ') .replace(/([a-z0-9])([A-Z])/g, '$1 $2'); @@ -250,13 +269,11 @@ export const sharedEntityOptions = ( if (completeEntries.length === 0 || completeEntries.length !== entries.length) return undefined; const [first, ...rest] = completeEntries; - const options = first.options.filter((option: any) => { - const dcid = option?.value?.ref?.ref; + const options = first.options.filter((option) => { + const dcid = option.value.ref?.ref; return ( dcid && - rest.every((entry) => - entry.options.some((other: any) => other?.value?.ref?.ref === dcid), - ) + rest.every((entry) => entry.options.some((other) => other.value.ref?.ref === dcid)) ); }); @@ -647,28 +664,24 @@ export const planPlaceOptions = ( * the years dimension ("24 yrs"), else nothing. Slot options read * `optionSeriesLabel` instead. */ -export const optionCoverageLabel = (option: any): string | null => { - if (typeof option?.obs_count === 'number') { - return `${formatCount(option.obs_count)} obs`; - } - +export const optionCoverageLabel = (option: OptionLike | null | undefined): string | null => { const coverage = option?.coverage; if (!coverage) { return null; } - const dims: any[] = coverage.dimensions ?? []; + const dims = (coverage.kind === 'bare' ? null : coverage.dimensions) ?? []; const obs = - typeof coverage.observation_count === 'number' + coverage.kind === 'exact' ? coverage.observation_count - : dims.find((d) => d?.label === 'observations')?.count; + : dims.find((d) => d.label === 'observations')?.count; if (typeof obs === 'number') { return `${formatCount(obs)} obs`; } - const years = dims.find((d) => d?.label === 'years')?.count; + const years = dims.find((d) => d.label === 'years')?.count; if (typeof years === 'number') { return `${formatCount(years)} yrs`; @@ -683,7 +696,7 @@ export const optionCoverageLabel = (option: any): string | null => { * (dfo-apis src/search/models/options.py) is populated on every option or * none for a given list, so checking one option settles it for the rest. */ -export const isLevelScopedOptions = (options: any[]): boolean => +export const isLevelScopedOptions = (options: readonly SlotOption[]): boolean => options.length > 0 && options[0]?.relation != null; /** @@ -697,12 +710,12 @@ export const isLevelScopedOptions = (options: any[]): boolean => * parent, child, or root option. */ export type LevelPartition = { - parent: any | null; - children: any[]; - roots: any[]; + parent: SlotOption | null; + children: SlotOption[]; + roots: SlotOption[]; }; -export const partitionLevelOptions = (options: any[]): LevelPartition => ({ +export const partitionLevelOptions = (options: readonly SlotOption[]): LevelPartition => ({ parent: options.find((option) => option?.relation === 'parent') ?? null, children: options.filter((option) => option?.relation === 'child'), roots: options.filter((option) => option?.relation === 'root'), @@ -710,7 +723,7 @@ export const partitionLevelOptions = (options: any[]): LevelPartition => ({ /** One root of an unbound level-scoped slot's two-level menu, with the * children the wire sent under it. */ -export type RootGroup = { root: any; children: any[] }; +export type RootGroup = { root: SlotOption; children: SlotOption[] }; /** * An unbound hierarchical slot's roots, each with its children grouped by @@ -721,10 +734,10 @@ export type RootGroup = { root: any; children: any[] }; * served count is that child alone. */ export const rootGroups = (partition: LevelPartition): RootGroup[] => { - const byParent = new Map(); + const byParent = new Map(); for (const child of partition.children) { - const parent: string | undefined = child?.parent; + const { parent } = child; if (!parent) continue; @@ -738,7 +751,7 @@ export const rootGroups = (partition: LevelPartition): RootGroup[] => { } return partition.roots.map((root) => { - const dcid: string | undefined = root?.value?.ref?.ref; + const dcid = root.value.ref?.ref; const children = dcid ? (byParent.get(dcid) ?? []) : []; const [only] = children; @@ -746,7 +759,7 @@ export const rootGroups = (partition: LevelPartition): RootGroup[] => { // pick twice ("project-type intervention (group)" and "project-type // intervention"): the child stands for both. return children.length === 1 && - root?.served_rows != null && + root.served_rows != null && only?.served_rows === root.served_rows ? { root: only, children: [] } : { root, children }; @@ -769,17 +782,26 @@ export const rootGroups = (partition: LevelPartition): RootGroup[] => { */ export type LevelMenu = | { kind: 'unbound'; groups: RootGroup[] } - | { kind: 'bound'; up: any | null; current: any; down: any[]; sideways: RootGroup[] }; - -export const levelMenu = (binding: any, options: any[]): LevelMenu => { + | { + kind: 'bound'; + up: SlotOption | null; + current: SlotOption; + down: SlotOption[]; + sideways: RootGroup[]; + }; + +export const levelMenu = ( + binding: Slot['binding'] | null | undefined, + options: readonly SlotOption[], +): LevelMenu => { const partition = partitionLevelOptions(options); const boundValue = binding?.kind === 'value' ? binding.value : null; - const boundDcid: string | undefined = boundValue?.ref?.ref; + const boundDcid = boundValue?.ref?.ref; - if (!boundDcid) return { kind: 'unbound', groups: rootGroups(partition) }; + if (!boundValue || !boundDcid) return { kind: 'unbound', groups: rootGroups(partition) }; const { current, rest } = splitCurrentPick(partition.roots, boundDcid); - const isDown = (child: any) => child?.parent === boundDcid; + const isDown = (child: SlotOption) => child.parent === boundDcid; return { kind: 'bound', @@ -798,7 +820,7 @@ export const levelMenu = (binding: any, options: any[]): LevelMenu => { * accumulate it: the whole `{options, truncated}` pair the wire event * carries, not just the option list — the shape every menu renders from * (see mergeSlotOptionsEntry below). */ -export type SlotOptionsEntry = { options: any[]; truncated: boolean }; +export type SlotOptionsEntry = { options: SlotOption[]; truncated: boolean }; const EMPTY_OPTIONS_ENTRY: SlotOptionsEntry = { options: [], truncated: false }; @@ -831,7 +853,7 @@ export const mergeSlotOptionsEntry = ( * reads the taxonomy label directly. The one check both a bound slot's * menu and Add Criteria's submenu need, so the same slot doesn't gain a * different label depending on which entry point rendered it. */ -export const slotOptionLabel = (slot: any, option: any): string => +export const slotOptionLabel = (slot: any, option: OptionLike): string => slot?.key?.axis === 'source' ? sourceOptionLabel(option) : optionLabel(option); /** "1,284 rows", or "up to 1,284 rows" when `exact` is false, for a count @@ -892,7 +914,7 @@ export const servedSeriesCount = (spec: any): number | null => * that serves every row, narrows nothing. */ export const slotOffersChoice = (slot: any, entry: SlotOptionsEntry, spec: any): boolean => { const bound = new Set(slotBoundDcids(slot)); - const binds = (option: any) => bound.has(option?.value?.ref?.ref); + const binds = (option: SlotOption) => bound.has(option.value.ref?.ref ?? ''); if (bound.size > 0 && !entry.options.every(binds)) return true; if (bound.size === 1) return false; @@ -909,7 +931,7 @@ export const slotOffersChoice = (slot: any, entry: SlotOptionsEntry, spec: any): /** True once the engine confirmed no data for an entity option * (optionCoverage's 'probed-zero'): the row stays in its menu but reads * muted rather than vanishing, whether or not it's also the current pick. */ -export const optionMuted = (option: any): boolean => +export const optionMuted = (option: OptionLike | null | undefined): boolean => coverageState(option?.coverage) === 'probed-zero'; /** @@ -924,10 +946,13 @@ export const optionMuted = (option: any): boolean => * shape (`value.ref.ref`) or PlaceScopeEditor's flat EntityOption shape * (`dcid`). */ -export const rankOptionsByCoverage = (options: any[], boundDcid?: string | null): any[] => { +export const rankOptionsByCoverage = ( + options: T[], + boundDcid?: string | null, +): T[] => { const { main, hidden } = partitionOptions(options, { - state: (option) => coverageState(option?.coverage), - richness: (option) => coverageRichness(option?.coverage), + state: (option) => coverageState(option.coverage), + richness: (option) => coverageRichness(option.coverage), label: (option) => optionLabel(option), isPinned: (option) => boundDcid != null && entityOptionDcid(option) === boundDcid, }); @@ -1706,7 +1731,7 @@ const LEGACY_SLOT_HEADINGS: Record = { how: 'Sector', what: 'Typ // The cells of one series' rows: one per entity role and one per axis // property, or, without axes, the subject entity and the how and what // slots' bound values. -const seriesCells = (s: any, spec: any, axes: boolean): Record => { +const seriesCells = (s: ObservationSeries, spec: any, axes: boolean): Record => { if (!axes) { const subject = seriesEntity(s); @@ -1878,7 +1903,7 @@ export const previewColumns = (parts: { spec: any }[]): PreviewColumn[] => { }; // A series' party kind per entity column. -const seriesKinds = (s: any): Record => { +const seriesKinds = (s: ObservationSeries): Record => { const kinds: Record = {}; for (const entity of s?.entities ?? []) { @@ -1944,26 +1969,31 @@ export const previewCellText = ( // `conversion` record), where the Spec's identity states the basis the // values were converted to. A preview that could not apply the conversion // keeps the unit the points are in. -const seriesUnit = (series: any, spec: any): string | null => +const seriesUnit = (series: ObservationSeries, spec: any): string | null => (series?.conversion ? unitWithBasis(spec) : null) ?? formatUnit(series?.facet?.unit) ?? formatUnit(spec?.facets?.unit); +/** The observation series a Spec carries; none before its observations + * arrive. */ +export const observationSeries = ( + spec: Pick | null | undefined, +): ObservationSeries[] => spec?.observations?.series ?? []; + export const flattenRows = (spec: any): ResultRow[] => { - const series = spec?.observations?.series ?? []; const axes = carriesAxes(spec); - const rows: ResultRow[] = series.flatMap((s: any) => { + const rows: ResultRow[] = observationSeries(spec).flatMap((s) => { const cells = seriesCells(s, spec, axes); const kinds = seriesKinds(s); - return (s?.points ?? []).map((point: any) => ({ - year: String(point?.date ?? '—'), + return s.points.map((point) => ({ + year: point.date, cells, kinds, - value: typeof point?.value === 'number' ? point.value : null, + value: point.value, unit: seriesUnit(s, spec), - method: formatMethod(s?.facet?.measurementMethod), + method: formatMethod(s.facet?.measurementMethod), })); }); @@ -1976,7 +2006,7 @@ export type MethodChoice = { label: string; checked: boolean; /** The facet binding a pick posts. Null drops the axis. */ - binding: IdentityFacets['measurementMethod'] | null; + binding: IdentityFacetBinding['measurementMethod'] | null; }; export type MethodPicker = { label: string; choices: MethodChoice[] }; @@ -2053,11 +2083,12 @@ export const methodPicker = (spec: any): MethodPicker | null => { return null; } - const bound: IdentityFacets['measurementMethod'] = spec?.identity?.facets?.measurementMethod; + const bound: IdentityFacetBinding['measurementMethod'] = + spec?.identity?.facets?.measurementMethod; const boundRefs: (string | null)[] = bound?.state === 'set' ? bound.refs : bound && 'ref' in bound ? [bound.ref] : []; const served = servedMethod(spec); - const optionRef = (option: any): string | null => option.value.ref?.ref ?? null; + const optionRef = (option: SlotOption): string | null => option.value.ref?.ref ?? null; if (boundRefs.length === 0 && options.length === 1 && served.ref === optionRef(options[0])) { return null; @@ -2274,7 +2305,7 @@ export const flattenPartsRows = ( // A part's own outcome, in plain language, for its variable line and for a // marked unresolved phrase's hover title. Both surfaces read this one map, // so they say the same thing for the same reason code. -const NO_DATA_REASON_TEXT: Record = { +const NO_DATA_REASON_TEXT: Record, string> = { source_not_resolved: "We couldn't match the data source named in that query.", source_ambiguous: 'More than one data source matches that wording. Try naming it in full.', source_code_not_held: 'No source we search publishes that code for this question.', @@ -2304,13 +2335,15 @@ const NO_DATA_REASON_TEXT: Record = { }; export const noDataReasonText = (reason?: NoDataReason | null): string => - (reason && NO_DATA_REASON_TEXT[reason]) || 'No data matched this query.'; + reason && hasKey(NO_DATA_REASON_TEXT, reason) + ? NO_DATA_REASON_TEXT[reason] + : 'No data matched this query.'; // Names the failed gate for every reason pairing a flagged_match with one. // The flagged_match of role_incompatible, qualifier_not_bound and // denominator_not_available carries no gate since no availability verdict // was taken. -const NO_DATA_GATE_TEXT: Record = { +const NO_DATA_GATE_TEXT: Record, string> = { no_member_has_data_at_place: 'That match has no data at this place.', no_member_asserts_bound_properties: "That match doesn't carry the condition this query needs.", only_alternate_has_data: 'Only a different variable has data for this request.', @@ -2386,7 +2419,7 @@ export const noDataText = ( if (compared) return `${base} ${compared}`; } - if (noData?.gate) { + if (noData?.gate && hasKey(NO_DATA_GATE_TEXT, noData.gate)) { return `${base} ${NO_DATA_GATE_TEXT[noData.gate]}`; } diff --git a/src/lib/sources/graph.ts b/src/lib/sources/graph.ts index f049674..8f71f92 100644 --- a/src/lib/sources/graph.ts +++ b/src/lib/sources/graph.ts @@ -13,6 +13,7 @@ * at most once an hour however many people open it. */ +import type { ProblemDetails } from '@developmentfinance/client/wire'; import { unstable_cache } from 'next/cache.js'; import { cache } from 'react'; @@ -139,7 +140,7 @@ const cachedGraphRead = unstable_cache( }); if (res.status !== 200) { - const body = (await res.json().catch(() => null)) as { code?: string } | null; + const body = (await res.json().catch(() => null)) as Partial | null; throw new GraphAnswer(res.status, body?.code); } diff --git a/vendor/developmentfinance-client-0.1.0.tgz b/vendor/developmentfinance-client-0.1.0.tgz new file mode 100644 index 0000000..bf2da8c Binary files /dev/null and b/vendor/developmentfinance-client-0.1.0.tgz differ