Appearance
Drupal projects-gallery-index contract (#378)
How NanaSelect ingests project taxonomy from the nanawall.com projects_gallery_index JSON:API view, and exactly which fields are part of the contract. The typed source of truth is src/lib/server/drupal/projects-contract.ts; the fetch is projects-fetch.ts and the sync is projects-sync.ts.
This is the sibling of the product-term contract: that one owns per-product attributes and imagery; this one owns the project → product taxonomy graph the projects view exposes.
Scope (taxonomy + rich metadata)
The projects_gallery_index view is an index of projects, not of media. Probed live (2026-07-03), its rows are taxonomy_term--projects and it exposes project taxonomy (the term name) and product taxonomy (field_products) cleanly. The gallery image bytes and Wistia ids are not on this view — they live 2–3 hops deeper (project term → field_case_study / node--case_study → field_content_items / paragraph--segment → nested paragraphs), and no Wistia field surfaced on that path during discovery. So this contract captures the taxonomy the view exposes and defers the image/Wistia model — see Deferred below.
Rich per-project metadata (#416)
The name-only #378 slice is enriched with the rest of the metadata the view exposes — location, the firms involved, the canonical nanawall.com slug, and references to the case study / hero image — so real-project matching (#345) and project displays are substantive rather than name-only.
Source decision (#416 AC-1, confirmed live 2026-07-04): stay on projects_gallery_index, no expanded include, app_projects NOT adopted. A JSON:API views resource returns full taxonomy_term--projects entity resources, so every rich field is already present in each row with the existing request:
- Rich scalars —
field_city,field_state,field_location(a geo point),field_architect(+_website),field_construction_company(+_web),field_design_build_firm,field_featured,field_gallery_order— and the canonicalpath.aliasslug arrive in the row'sattributes. - The
field_case_study(→node--case_study) andfield_hero_imagerelationships are captured as bare reference UUIDs fromrelationships.*.data.id. #416 stores the reference, not the referenced bytes, so no include is needed for them either — the include set staysfield_productsonly.
field_hero_image is near-universally empty on nanawall projects (0/18 on page 1); the real project imagery is the media--image collection that references up to these project UUIDs (#400, below), not this term's own hero field.
Transport
JSON:API views resource: GET /jsonapi/views/projects_gallery_index/page_1 with Accept: application/vnd.api+json. Base URL is DRUPAL_BASE_URL (default https://www.nanawall.com).
Pagination
A view resource, unlike the taxonomy_term collection the product contract uses, carries its own pager:
| Behaviour | Detail |
|---|---|
page[limit] | Rejected — HTTP 400 ("page" contains a non-scalar value). |
| Next page | Signalled by links.next, whose href is ?page=N (0-indexed). |
| Fetcher | Walks ?page=0,1,2…, stops when a page has no links.next, bounded by MAX_PAGES (200). Resumable — a caller can start at any page. |
Includes
?include=field_products is required. The relationship data carries only the product term UUID + meta.drupal_internal__target_id (tid); the product name — the join key to systems — lives on the included taxonomy_term--products resources. Without the include, product names are null.
The contract shape
Per project row (taxonomy_term--projects):
| Source (JSON:API) | NanaSelect field | Notes |
|---|---|---|
id | DrupalProject.uuid | Durable idempotency key. |
attributes.drupal_internal__tid | DrupalProject.tid | Internal tid, when present. |
attributes.name | DrupalProject.name | The "project taxonomy" value. |
relationships.field_products.data[] | DrupalProject.products[] | Product taxonomy; de-duped by UUID in field order. |
Rich per-project metadata (#416), on DrupalProject.meta — all optional, every field degrades to null on absence so an enriched shape is a strict superset of the #378 name-only project:
| Source (JSON:API) | NanaSelect field | Notes |
|---|---|---|
attributes.path.alias | meta.slug | Canonical nanawall.com path (AC-3). Absolute URL = DRUPAL_BASE_URL + slug. |
attributes.field_city | meta.city | |
attributes.field_state | meta.state | |
attributes.field_location.lat / .lon | meta.lat / meta.lon | Geo point → coordinates for geography matching (#345). |
attributes.field_architect (+ _website) | meta.architect / meta.architectWebsite | |
attributes.field_construction_company (+ _web) | meta.constructionCompany / meta.constructionCompanyWeb | |
attributes.field_design_build_firm | meta.designBuildFirm | |
attributes.field_featured | meta.featured | Boolean; false stays distinct from null. |
attributes.field_gallery_order | meta.galleryOrder | Lower sorts first. |
relationships.field_case_study.data.id | meta.caseStudyUuid | Bare reference UUID (→ node--case_study). |
relationships.field_hero_image.data.id | meta.heroImageUuid | Bare reference UUID; usually null (see Scope). |
Per product ref (taxonomy_term--products):
| Source | NanaSelect field | Notes |
|---|---|---|
id | DrupalProductRef.uuid | Product term UUID. |
included attributes.name | DrupalProductRef.name | Join key to drupal_system_mappings.drupal_term_name; null if the include didn't resolve it. |
included drupal_internal__tid / meta drupal_internal__target_id | DrupalProductRef.tid | Prefers the included tid, falls back to the relationship meta. |
Degradation ladder
Identical to the media fetch — the sync never throws on Drupal drift:
- A page fails (network/HTTP) or parses to garbage → stop, return what was gathered with
ok: false. A partial fetch is never treated as a deletion. - A malformed project / product ref → warn + skip, fetch continues.
Persistence
| Table | Holds |
|---|---|
drupal_projects | One row per project — uuid (PK), tid, name, source_synced_at, plus the #416 rich columns: slug, city, state, lat, lon, architect (+ architect_website), construction_company (+ construction_company_web), design_build_firm, featured, gallery_order, case_study_uuid, hero_image_uuid; and published — the Drupal term publish flag (#1744, NOT NULL DEFAULT 0 fail-safe, ingested from taxonomy_term_field_data.status by the dump load; buyer surfaces filter to published rows in selectProjectViews, admin surfaces see all). |
drupal_project_products | The project→product graph — (project_uuid, product_uuid) PK, cached product_name + product_tid. Reverse index on product_uuid for "which projects use this product" (#345). |
The #416 columns are populated by the same idempotent upsert: an existing project whose enriched fields changed is updated in place, and a partial fetch is never a deletion (identical semantics to #378). The enriched rows are read back by listEnrichedProjects() — the typed accessor #345 real-project matching consumes.
Sync semantics
- Idempotent — keyed by project UUID; a re-run against unchanged Drupal data writes nothing (counted
unchanged). - Additive — product associations are only ever added. A product dropped from a project in Drupal is left in place (drift, not a delete), the same never-detach contract as the media sync.
- Partial-fetch-safe — an incomplete fetch aborts before any write.
Trigger it from Admin → Drupal sync (previewProjects / applyProjects), or programmatically via runProjectsSync(db, actor, { baseUrl }).
Gallery images: the media--image collection (#400)
The imagery was deferred from #378 and delivered in #400 — once the product owner confirmed the model. Gallery images are not on the projects view; instead, media--image entities reference up to the projects/products taxonomy. So the images are read from the media collection directly, keyed to the same project/product UUIDs this doc's taxonomy sync persists.
Source of truth: media-image-contract.ts, media-image-fetch.ts, media-image-sync.ts.
Endpoint & pagination
GET /jsonapi/media/image?sort=drupal_internal__mid&include=field_media_image. The collection's page[offset] pager is broken on this Drupal (any page[offset]&page[limit] combo 400s with "page contains a non-scalar value" — even the server's own links.next). Pagination uses a drupal_internal__midcursor (filter[after]) instead — the same technique as the product/media fetchers. include=field_media_image resolves the file's uri.url.
Per media--image
| Source | NanaSelect | Use |
|---|---|---|
id | source_ref | idempotency key (reuses the referenced-media model) |
field_media_image → file--file | remote_url | image bytes (referenced, imported on demand) |
field_wistia_id | embed columns | present ⇒ video thumbnail — a type='image' still that also plays the Wistia video (#361) |
field_project → taxonomy_term--projects | project media tag | joins to drupal_projects.uuid |
field_products → product terms | system media tags | joins to systems via drupal_system_mappings.drupal_term_uuid |
Sync semantics
Idempotent (by source_ref), additive (tags are only added — manual tags and uploads are never touched), partial-fetch-safe. Trigger from Admin → Drupal sync (previewMediaImages / applyMediaImages) or runMediaImageSync(db, actor, { baseUrl }). A Wistia-backed image renders through VideoThumbnail.svelte — the still with a play affordance that swaps to the player.