Public API Foundation
The public API runs as @wavemap/public-api in apps/wavemap-public-api. It has its own listener, environment parser, database pool and request controls. Seventeen resource GET operations cover Events, Artists, Venues, Series and parent-scoped Editions; /v1/health and /v1/ready bring the inventory to nineteen. Resource reads support HEAD, browser preflight, representation validation and the family-specific refresh controls described in Public API Resources. This page describes local developer behavior; public deployment and reference publication remain separate approved workflows.
Ownership
Section titled “Ownership”| Owner | Responsibility |
|---|---|
@wavemap/api-contracts/public-api | Public identities, envelopes, schemas, unversioned/versioned route declarations, route inventory and operation IDs. Its explicit subpath keeps the public contract separate from main-app envelopes. |
apps/wavemap-public-api/src/app.ts | Pure Hono composition, global protection/error handling and one version-prefix mount. Importing it does not load credentials, connect to PostgreSQL, or listen. |
apps/wavemap-public-api/src/routes and /handlers | Grouped routers and named handler tuples, following the main backend structure. Handlers attach operation declarations, validate requests and compose responses; readiness receives an injected probe. |
apps/wavemap-public-api/src/http/operations | Complete typed OpenAPI operation declarations grouped by resource, composing canonical schemas, operation IDs, examples and shared HTTP metadata. |
apps/wavemap-public-api/src/runtime | Configuration, pool, admitted read work and listener lifecycle. Only src/bin/startServer.ts reads the executable environment. |
@wavemap/data-access/schema and /schema/* | Canonical Drizzle schema imported directly by both servers. Private table definitions remain in this graph for relational consistency; importing a schema grants no database access. |
@wavemap/data-access/reads/* and /media | Injected resource reads, neutral bounded hydration, and pure public URL construction. Reads receive the database and media context; they do not import backend singletons or storage-write SDKs. |
apps/wavemap-back-end/src/db | Migration journal, migration execution, seeds and main-app connections. The public service never migrates or seeds at startup. |
Consumers import schema, database types and pure read/media helpers directly from the owning @wavemap/data-access export subpaths. Backend compatibility-forwarding files have been removed. Main-app wrappers preserve existing read signatures and output policy only where they supply database, media-delivery or response-validation context. Public handlers must still construct explicit public DTOs: shared read rows can contain internal join keys and storage locators, and must not be serialized wholesale. Database grants limit accessible columns; lifecycle and location-publication rules remain query/projection responsibilities.
The backend Drizzle configuration reads packages/data-access/src/schema/index.ts directly. Migration files, the journal, runner and seeds remain backend-owned. The existing application-delivery check requires manual database review for changes under the canonical schema directory and retains its former-path check for historical diffs. Shared read-only helper changes alone do not trigger that schema-path rule.
The shared route inventory and operation-ID mapping feed both handler metadata and safe completion labels. Contract tests compare actual mounted routes, generated OpenAPI operations and GET/HEAD/OPTIONS log labels. Unversioned paths are registered inside grouped routers and receive /v1 once at application composition. Parameterized routes resolve to fixed operation labels before validation; public IDs and arbitrary path values do not enter completion logs. The internal backend and inspected Waveguide ops router use the same router/handler separation; the public service intentionally keeps dependency injection and its own minimal envelopes instead of importing either application’s environment or database singleton.
Contract Organization
Section titled “Contract Organization”Within packages/api-contracts/src/public-api, contracts.ts owns foundational identities and envelopes. contracts/shared owns reusable query, resource and response-validation primitives; contracts/events, artists, venues, eventSeries and eventSeriesEditions own their resource schemas and responses. Field/sort constants, inferred types and query normalizers follow the same ownership. utils/query.ts and utils/timestamps.ts contain resource-neutral parsing helpers. Small reference leaves prevent recursive imports between Event, Series and Edition resources.
Relationship contracts follow the addressed parent: Event Artist/Venue and reverse Series/Edition routes stay in Event modules; Series and Edition traversal belongs to those parents. They reuse child projections without exporting authoring responses or recursively expanding children. Keep shared primitives independent of resource modules and use direct leaf imports internally. Consumers continue importing from @wavemap/api-contracts/public-api; its entrypoint exposes the reviewed schemas, constants, types and normalizers while keeping composition helpers internal.
Event Reads
Section titled “Event Reads”Collections default to 20 items and cap at 50. Event detail and collection reads can select five-item Artist, Venue, direct-Series and Edition-program previews; each preview has a complete traversal route. There is no authentication, exact total, offset pagination, recursive inclusion or frozen-snapshot guarantee. Cancelled Events remain readable; inactive Events and inactive related entities are excluded. Venue operational status is independent of entity visibility. The resource reference describes Event detail defaults, context filters, named filters and exact-start ordering.
For a Venue calendar, issue a request such as the following, replacing the illustrative public ID:
GET /v1/events?venueId=Venue1234567&startLocalDateGte=2026-09-01&startLocalDateLt=2026-10-01&include=artists,venues&limit=20venueId applies public-ID equality. startLocalDateGte is inclusive and startLocalDateLt exclusive; each is independently optional. When both are present, the lower bound must precede the upper. Filters combine with AND and compare the organizer’s start-local-date, not an interval-overlap or viewer-time-zone calculation. Supported dates use years 0001–9999. Unknown or inactive Venue filters return an empty successful page.
sort=startLocalDate is the default; sort=-startLocalDate reverses dates and timed instants. Within each date, timed Events precede date-only Events in both directions, and public ID ascending breaks ties. Date-only boundaries contain no invented instant or local time. An unknown end boundary is omitted. Schedule temporal classification is computed at request time.
Field Selection And Inclusion
Section titled “Field Selection And Inclusion”| Resource | Default Fields | Additional Selectable Fields |
|---|---|---|
| Event | publicId, slug, name, schedule, administrativeStateCode, profileMedia, artists, venues | updatedAt, description, eventTypeCode, digitalLocations, links, ticketLinks, directEventSeries, editionPrograms |
| Artist preview | publicId, slug, name, profileMedia | include=artists adds description |
| Venue preview | publicId, slug, name, statusCode, location, profileMedia | include=venues adds description, capacity, contact, publishedCoordinates, links |
fields replaces the default top-level selection, while publicId always remains. Composite fields such as schedule, contact and profileMedia are atomic. Dotted paths, unsupported values, duplicate CSV members, empty values, repeated scalar parameters and unknown parameters return 400. CSV whitespace and ordering do not change the effective selection. Inclusion upgrades a selected relationship by one level; fields=name&include=artists is rejected. Inclusion without fields retains Event defaults.
For example, GET /v1/events?fields=name&limit=20 can return:
{ "ok": true, "data": [{ "publicId": "AbCdEf123456", "name": "Evening Concert" }], "meta": { "pagination": { "limit": 20, "hasMore": false } }, "links": { "next": null }}Selected absent nullable values are null; unselected values are omitted. Empty eligible collections are []. Previews use { items, isComplete, href }: isComplete=false means more eligible members existed at that read, and href addresses a working relationship route. Follow that route for all members, then its links.next until null. Artist/Venue relationship routes default to the rich projection, accept their own fields, limit, cursor and sort=name, and order names then public IDs bytewise (COLLATE "C"). They reject inclusion. An addressed missing/inactive Event returns 404; an active Event with no eligible members returns an empty 200.
Profile media exposes only publicId, src, nullable thumbnailSrc, nullable altText, and nullable dimensions. Only a ready profile is selected, with deterministic ordering for legacy Artist profiles. Upload names, original filenames, inline blur data and storage locators stay private. A Venue without an address reports location.stateCode=tba; coordinate-only and formatted locations retain the canonical location semantics. Exact coordinate values appear only when publication explicitly permits them; otherwise publishedCoordinates is null.
Complete public URL collections are capped at 25, with an extra SQL sentinel to detect invalid persisted overflow. Overflow or an invalid selected persisted projection fails through a sanitized 500, rather than silently truncating a complete value. Standalone Artist detail includes bounded links; existing Event Artist projections retain their earlier field set. Galleries remain excluded. Authoring revisions, actors, authoring timestamps, internal join IDs, address provenance and time-zone suggestions are excluded. The separately selected public updatedAt has the change scope described below.
Continuation
Section titled “Continuation”Follow links.next as a relative URL against the API origin. It preserves effective controls and carries an opaque, versioned cursor bound to the route/parent, filters, sort, limit, fields and includes. Equivalent defaults and CSV ordering produce deterministic links. Changing an effective control or presenting malformed/incompatible cursor content returns 400. Cursors encode positions, not authorization credentials. Event cursor instants preserve PostgreSQL microseconds without a JavaScript Date round trip; continuation does not require the boundary Event to remain visible.
Reads use multiple autocommit statements. Concurrent edits can move or remove records and can change relationships between statements or pages. Deduplicate by public ID and periodically repeat a complete traversal when maintaining a local view. Incremental absence does not establish deletion. Successful resource representations can remain fresh in an HTTP cache for 60 seconds; a complete traversal is still not a frozen snapshot.
The maximum cursor length is 1,024 characters. Ordinary name cursors retain the full name/public-ID boundary and need no row lookup. When an encoded boundary exceeds that limit, it uses the public ID and a SHA-256 name fingerprint. Event Edition-membership ordering retains four keys: Series name/ID and Edition name/ID; its compact variant fingerprints both names. The next request resolves an eligible member under the existing admitted reader before applying the same bytewise ordering. A renamed, inactive, unlinked or physically removed compact boundary returns 400 invalid_query with an instruction to restart without a cursor; it never silently substitutes a new ordering key. An absent/inactive addressed parent still returns 404. Calendar, exact-start and refresh Event cursors remain self-contained. A compact fallback adds at most one SQL statement while remaining within the eight-statement budget.
Collection admission reserves the full cursor capacity in the normalized absolute URL, including default parameters and the continuation parameter, before database work. Long combinations can therefore return 414 even when the incoming URL alone fits, or 400 when the configured parameter budget cannot accommodate pagination. This conservative reservation applies even when a particular short cursor or empty result could fit; accepted collections can safely emit their next link.
Public Change Markers And Incremental Reads
Section titled “Public Change Markers And Incremental Reads”Request GET /v1/events?updatedSince=2026-09-14T00%3A00%3A00Z&sort=updatedAt&fields=name,updatedAt. updatedSince requires explicit sort=updatedAt and fields containing updatedAt. Input accepts UTC seconds with zero through six fractional digits, years 0001–9999; offsets, missing seconds and excess precision are rejected. Equivalent fractions normalize to six digits for cursor binding. Output always retains six fractional digits. Compare these canonical strings directly; converting the checkpoint to JavaScript Date would lose its final three digits. sort=updatedAt without a lower bound supports complete reconciliation. Ordinary calendar defaults remain unchanged.
The lower bound is inclusive. Results and continuation use ascending (updatedAt, publicId) with exact PostgreSQL precision; ties and retry overlap must be deduplicated by public ID. Advance a local checkpoint only after every page succeeds, using the greatest returned marker. Empty results retain the previous checkpoint. Never derive progress from local wall time, HTTP Date, Age, request duration or receipt of a 304: these describe delivery or validation, not when the rows changed.
events.publicUpdatedAt is a string-mode timestamptz(6) exposed only through public updatedAt. Backend migration 0055_event_public_refresh_marker initializes retained Events at one migration cutover; it does not reconstruct historical changes. New Events get a database clock default. Changed writers advance monotonically by at least one microsecond under the Event lock, in the same transaction as the owned mutation. Existing authoring timestamps, actors and revisions keep their original meaning.
Marker ownership includes meaningful Event scalar/schedule/state changes, Event-owned Artist/Venue and URL memberships, memberships written through Artist/Venue authoring (including initial associations), Artist-deletion membership cleanup, and eligible ready profile-media changes. Secondary Event locks precede relationship DML; busy targets fail atomically with a conflict rather than reverse existing owner lock order. Upload storage precedes finalization and retains compensation on failure; profile selection and synchronization are checked under the Event lock. Seeds assign their final baseline after composing content and memberships. No-ops, gallery-only changes, non-ready media and unrelated upload metadata leave the marker stable. Profile comparison conservatively includes canonical storage references and fallback URLs because reader processes resolve them independently; a reference rewrite can advance the marker even when one runtime resolves the same URL.
Independent Artist, Venue, Series or Edition edits and time-derived schedule phases can change an Event representation without advancing the Event marker. Series/Edition-owned membership insertion/removal, lifecycle changes and selected parent/window edits follow that same ownership rule. An Event that is removed, deactivated or leaves a context, location, date or phase filter cannot announce its absence through that filtered collection. Use representation validators and periodic complete reconciliation. The marker is assigned before transaction commit: a sufficiently delayed commit can appear behind a completed traversal, and no finite overlap establishes a lossless change feed.
Conditional Reads And Cache Freshness
Section titled “Conditional Reads And Cache Freshness”All successful resource reads return Cache-Control: public, max-age=60 and a weak ETag. The public app serializes the validated selected body once and hashes all of it, including relationships, pagination and the deterministic next link. Request identifiers and delivery time remain headers and do not affect the validator. Field/include variants validate their actual representation; unchanged unselected content need not change the tag. Included related edits and time-derived schedule changes invalidate the tag when they change the body.
Send If-None-Match with the previous tag for that exact URL. Matching weak/strong forms, lists and * return bodyless 304 for GET or HEAD, with ETag, HTTP Date, cache policy and anonymous CORS headers. Malformed fields are ignored in full; they cannot produce a match from a valid fragment. Unchanged empty collections also validate. HEAD returns the same validator and no body. Errors and operational routes stay no-store, without resource validators. This follows RFC 9110 conditional comparison and response semantics and RFC 9111 cached-response validation.
A 304 reuses the previously stored body; never parse it as JSON or treat its delivery time as a newer change checkpoint. Origin conditional requests still perform admitted database reads, projection, response validation, serialization and hashing. They save response-body transfer; a fresh browser/shared-cache hit can also avoid an origin read. Weak validators allow transfer encoding to vary without promising byte-range equivalence. The local origin does not configure CloudFront: cache-key/query forwarding, deployed CORS, removal handling and operational policy still require the deployment pass.
Read Ownership And Cost
Section titled “Read Ownership And Cost”data-access/reads/events/publicEventSelection reuses canonical visibility, filter predicates and temporal classification, with keyset continuation and no count. Resource-owned relationship and URL reads apply per-parent SQL limits before enrichment. Event/Artist/Venue profiles share one owner-tagged statement without mixing identity namespaces. publicEventPage coordinates at most eight statements and three simultaneous statements, including selected reverse memberships. Public app adapters select explicit values and handlers validate the response against the effective request before serialization. The backend owns migrations and writer integration; authoring timestamps, actors and revisions remain independent.
The maximum rich Event fixture returns 11,801 SQL rows, including sentinels, across eight statements with at most three concurrent. It includes 50 Events, five previews per selected relationship, and bounded URL collections. Sentinel owners are not hydrated. Sparse selections avoid unrequested relationship, media and URL work. The resource budget summary identifies the other family bounds. Sorting can inspect more rows than it returns; launch-scale plans, bytes, memory, validation cost and main-app contention remain capacity work.
Reusable public runtime types live in src/types.ts; environment coercion and URL predicates live in src/utils/validation.ts, separate from configuration declarations. Shared read rows have resource-owned and query-control type modules. Schema support values and types live in schema/constants.ts and schema/types.ts. Media purpose/status vocabularies come from api-contracts. Shared media adapters throw validation failures with the underlying error as their cause and do not write logs; the calling application’s error policy decides how to use those diagnostics.
Standalone Venue Reads
Section titled “Standalone Venue Reads”GET /v1/venues discovers active Venues independently of Event membership. GET /v1/venues/{venueId} reads one active Venue; missing or inactive identities return an uncached 404, including conditional requests. Related Events remain at /v1/events?venueId=…; neither Venue operation recursively includes them.
| Control | Meaning |
|---|---|
nameContains | Case-insensitive literal name substring. %, _, backslash and ! are literal text; no fuzzy or accent-folded matching. |
locality | Case-insensitive equality, without accent folding; absent locality matches no value. |
countryCode | Exact uppercase two-letter code equality, independently of country names. |
statusCode | Canonical operational status (operational, on-hiatus, permanently-closed), independently of active visibility. |
sort | name by default, -name, or ascending updatedAt. Public ID ascending breaks every tie. |
fields | Atomic top-level selection; public identity is always present. |
limit, cursor | Default 20, maximum 50; follow the server’s continuation without changing effective controls. |
updatedSince | Inclusive exact UTC marker; requires explicit sort=updatedAt and selected updatedAt. |
Filters combine with AND. Name/locality values are trimmed after validation, limited to 200 characters and reject blank or NUL input. Stored locality is matched after the same whitespace trimming used by public location projection, so normalizing a retained address cannot silently change discovery membership. Unknown, repeated scalar and unsupported controls fail before read admission. Detail accepts only fields. Both name directions use bytewise COLLATE "C" comparisons with ascending public-ID ties. An ordinary cursor remains usable after its boundary disappears; an oversized-name cursor resolves only an active Venue satisfying every original filter and requires restart if that name changed or became ineligible.
Collection defaults are publicId, slug, name, statusCode, location and profileMedia. Detail defaults add description, capacity, contact, publishedCoordinates, links and the independent updatedAt. PublicAPIVenueDetailsDTO and the standalone read-field constants extend Venue meaning without changing Event previews, Event relationship defaults or the existing PublicAPIVenueDTO. Coordinate publication, media privacy and complete 25-link bounds are shared with Event Venue projections. Both standalone operations use the same GET/HEAD validators and 60-second freshness policy described above.
Venue Marker Ownership
Section titled “Venue Marker Ownership”venues.publicUpdatedAt is string-mode timestamptz(6), exposed as standalone updatedAt. Migration 0056_venue_public_refresh_marker gives retained rows one cutover timestamp, then installs the clock default, non-null constraint and refresh index. It does not reconstruct history. A new Venue gets its initial marker from the database; the development seed assigns a final baseline only to its inserted Venues after composing their content.
| Supported Writer | Transaction Boundary |
|---|---|
handlers/venues/createVenue.ts | Creates the owned content and retry receipt atomically; replay retains the original marker. |
handlers/venues/updateVenue.ts | Captures public content under the existing Venue lock before replacing addresses/links; advances after a meaningful projected change in the same transaction. |
handlers/venues/uploadVenueMedia.ts | Uploads externally first, then locks the Venue before ordering and ready-profile finalization; failed finalization compensates uploaded objects. |
handlers/venues/syncVenueMedia.ts | Locks before baseline and collection validation, preserves demotion-before-promotion, and stamps alongside cleanup staging. |
handlers/venues/deleteVenueMedia.ts | Locks before owned-target lookup and deletion, stamps with cleanup staging, then performs provider cleanup after commit. |
| Venue/Event association writers | Preserve Event marker updates; Event membership does not change the standalone Venue projection or its marker. |
Writer paths are relative to apps/wavemap-back-end/src. The focused helpers live in queries/venues/venuePublicChanges.ts, venuePublicContentChanges.ts and venuePublicMediaChanges.ts. Media locking uses nonblocking FOR NO KEY UPDATE SKIP LOCKED: a missing/busy Venue fails atomically with venue.update.revision-conflict. It adds no reverse Event/Edition lock. Marker advancement uses the greater of the database clock and the previous value plus one microsecond.
Scalar comparison includes persisted public identity/name/slug, description, capacity, operational status, contact, canonical location, published coordinates and sorted destination/URL links. Names and URLs compare verbatim because public output preserves them; contact and location follow public normalization. Equivalent snapshots, reordered links, private provenance/time-zone values and hidden-coordinate changes do not advance the marker. Existing authoring revisions, actors and timestamps retain their meaning, including accepted identical saves.
Profile comparison includes the deterministic ready profile’s public identity, alt text, dimensions, canonical/thumbnail storage references and fallback URLs. Gallery-only edits, non-ready records and private filename/MIME/blur metadata do not change the marker. Delivery-reference comparison is conservatively broader than one process’s resolved URL. Marker failure rolls back owned content, audits, retry receipts and cleanup staging; it cannot leave a successful mutation without its marker.
The supported-writer inventory includes authoring, media, association and seed paths. There is currently no supported whole-Venue delete/restore or asynchronous readiness writer; future writers must extend the inventory and proof. Arbitrary operator SQL is outside this guarantee. Venue markers do not propagate independent Event edits, and Event markers do not propagate independent Venue detail edits. Refresh remains a best-effort filtered read: late commits can fall behind an observed marker, while removals and filter exits require complete reconciliation. Retain microseconds, overlap and deduplicate; never infer deletion from incremental absence.
Venue Read Ownership And Proof
Section titled “Venue Read Ownership And Proof”reads/venues/publicVenuePage.ts owns standalone selection and filtered continuation; publicVenueSelection.ts and publicVenueLinks.ts also serve Event relationships. The shared Venue base row contains neither Event identity nor the standalone marker: each owning read adds its own value. reads/public owns only neutral bounds, profile hydration and persisted cursor/link/media types. App adapters/venues.ts explicitly formats selected public fields; http/operations/venues.ts owns complete route descriptions.
A rich Venue collection/detail uses at most three SQL statements, with two hydration statements concurrent. A collection selects at most 51 candidates, removes its sentinel, then hydrates only the 50 returned owners. The fixture measured 51 candidate rows, 50 ready-profile rows and 1,250 link rows; sparse and absent-detail reads need only their initial selection. Compact name lookup adds one statement. The restricted fixture adds only venues.publicUpdatedAt to its existing Venue column grants and still denies private provenance and DML with read-only mode disabled. The refresh-index test proves index participation, not full ordering coverage or production capacity; launch-scale planning and contention remain deployment work.
Focused proof lives in test/database/venueReads.test.ts (queries/grants/bounds), venues.test.ts (real HTTP projections/cursors/cache), and venueConsumer.test.ts (actual listener and Node example). Mocked input/spec tests remain separate. Backend Venue marker/content/media suites prove writer no-ops, retries, precision, contention, compensation and rollback. Existing Event projection and hydration tests protect the shared extraction.
Local Startup
Section titled “Local Startup”Build the public application and its workspace dependencies from the repository root:
pnpm build:public-apiFor an existing separately provisioned reader role, copy apps/wavemap-public-api/.env.example to that app’s ignored .env.dev, replace the placeholder database URL, and run:
pnpm dev:public-apiThis starts the public process on loopback port 6002. It reads only the app’s .env.dev and inherited PUBLIC_API_* settings. DATABASE_URL, backend auth secrets, and storage-write credentials are not substitutes for public-service configuration. Built startup uses pnpm -F @wavemap/public-api start with PUBLIC_API_* values supplied by its process environment.
Use pnpm dev:packages in a separate terminal when editing shared code. Its initial build and watchers include shared-utils, api-contracts, data-access, and i18n; pnpm dev:data-access selects the established TypeScript/alias watcher for that package alone. The ordinary combined pnpm dev retains the main application loop; start the public process explicitly. Node’s watch mode follows the imported package outputs. The existing backend Docker nodemon configuration watches app source only; after editing shared reads, restart that backend process to load the rebuilt package output. Automatic backend restarts for shared-package edits remain a separate local-workflow improvement.
Disposable Demonstration
Section titled “Disposable Demonstration”The public tests and demo reuse the backend’s isolated PostgreSQL fixture: loopback port 55433, database/user/password wavemap_db_backed_test. Use an existing disposable instance with that exact identity or prepare a separate PostgreSQL 16 instance for it. Never substitute the normal development database on port 5434.
A focused existing backend test performs the guarded migration/seed reset before checking the Event read. This command replaces the disposable fixture’s contents; stop other tests and the demo first:
pnpm -F wavemap-back-end exec vitest run --config vitest.db-backed.config.ts test/db-backed/__tests__/events/queryEvents.route.test.ts -t "Returns paginated events matching an unscoped query"Then choose either the interactive demo or the database proof:
pnpm -F @wavemap/public-api dev:fixture# Or, after stopping the demo:pnpm -F @wavemap/public-api test:dbThe demo provisions a temporary restricted role, starts the same public runtime on port 6002, and removes that role on SIGINT or SIGTERM. Individual read fixtures insert isolated records and clean up their captured IDs. The database test owner prepares the shared disposable baseline; dedicated lifecycle cases also run full resets. Forced termination can leave a role behind; rerunning the fixture resets its grants only after confirming no connections use it. These commands are mutually exclusive with other work on the disposable database.
For executable consumer examples, use the optional isolated read fixture after building workspace dependencies. It prints a temporary Venue ID and a complete browser URL:
pnpm -F @wavemap/public-api exec tsx test/database/serveFixture.ts --read-fixtureIn another terminal, serve the browser example on a different origin:
node apps/wavemap-public-api/examples/serve.mjsOpen the printed browser URL. The page uses ordinary fetch with credentials omitted, displays a live status and safely renders JSON as text. The Node example uses the same consumer helper; substitute the printed Venue ID:
node apps/wavemap-public-api/examples/server.mjs http://127.0.0.1:6002 VENUE_ID 2099-01-01 2099-01-03 1Both examples traverse five fixture Events across five pages with limit one. The Node example performs an initial complete reconciliation, a conditional reconciliation, then an incremental refresh. The browser offers the latter two actions explicitly. Repeating a reconciliation against unchanged data reports five reused 304 pages. Its explicit per-URL body/validator cache uses fetch with browser caching disabled so the demonstration can observe conditional responses; browser-managed caching remains a valid consumer option.
The shared controller owns one fixed Venue/date view, rejects concurrent refreshes, and commits its collection, body cache and precise checkpoint only after every page succeeds. It follows same-origin Event continuation URLs, rejects redirects, deduplicates public IDs while retaining the greatest observed marker, and stops at 100 pages. It retains only the last successful traversal’s cached URLs, bounding that example cache. Failed refreshes leave the previous usable view intact. A 429 or 503 reports Retry-After; caller-controlled retry/backoff is outside this example.
Incremental refresh subtracts five minutes from the greatest returned marker, retaining all six fractional digits. This exceeds the 60-second cache lifetime and the seconds-scale writes observed in focused local tests; it is an example setting, not a maximum transaction-duration guarantee. Cached delivery cannot advance the checkpoint beyond its returned rows. Empty incremental results preserve both the collection and checkpoint. Complete reconciliation replaces membership only after success, including removing filter exits and updating related details whose Event marker did not change. Run it periodically and after long outages; neither operation claims one point-in-time snapshot or certified completeness. Integrators should select their own reconciliation cadence and overlap from their freshness needs and observed write duration.
For standalone Venue discovery, the same read fixture supports this complete Node flow without copying a Venue ID:
node apps/wavemap-public-api/examples/venues-server.mjs http://127.0.0.1:6002 "Venue 000" 2099-01-01 2099-01-03 1The example discovers that Venue, fetches its rich public detail and five scoped Events, revalidates its discovery page, then reads incremental Venue candidates using overlap from the greatest returned Venue marker. It prints candidates separately: they must be merged by public ID and do not replace membership. examples/venues.mjs owns Venue request construction; examples/consumer.mjs retains the Event view controller. Both reuse examples/collection.mjs for bounded same-origin traversal, exact-marker deduplication and per-URL conditional bodies. The actual-listener test executes the checked-in Node script and separately demonstrates a late Venue commit recovered through overlap.
Stop both listeners after use; the API demo removes its inserted records before removing the role. These demonstration URLs are local, not published service addresses. Focused example regression proof runs with pnpm -F @wavemap/public-api exec vitest run test/consumer.test.ts; the cached and incremental PostgreSQL tests separately prove real reader behavior.
The disposable fixture uses the backend-owned reader reconciler and canonical column/function contract. Its explicit acknowledgement permits shared PUBLIC privilege changes only inside this guarded disposable target. The same reconciler has a separate operator-owned Development posture; see Public API Reader And Local Runtime for provisioning, inherited privilege review and retained-role migration/reset maintenance. Public startup never provisions or repairs privileges.
Runtime Bounds
Section titled “Runtime Bounds”Defaults are local experiment settings. Tune launch limits from representative endpoint measurements rather than treating these numbers as service guarantees.
| Resource | Initial Bound |
|---|---|
| PostgreSQL pool | Three connections; configurable up to twelve. |
| Admitted data reads | One operation, with no waiting queue; configuration reserves three pool slots per admitted read. |
| SQL work | At most eight statements per read; shared Event hydration currently fans out to three concurrent statements. |
| SQL deadlines | Three-second statement timeout; 500 ms lock timeout; two-second connection timeout. |
| Readiness | One shared in-flight SELECT 1 probe; concurrent callers reuse it. |
| HTTP work | At most 32 active route executions. |
| Rate allowance | Burst of 60; one token replenished per second per client bucket. |
| Limiter storage | At most 10,000 identities; five-minute idle expiry; at most 64 expiry checks per request. |
| Request shape | URL at most 4,096 bytes; at most 32 query parameters; repeated scalar parameters rejected. Health/readiness accept no query parameters or bodies. |
| Listener | 16 KiB headers, five-second header/request receive deadlines, five-second keepalive and at most 100 requests per socket. |
| Shutdown | Stop new admission, drain listener/pool, then force local connection closure after five seconds. |
PostgreSQL cancels slow statements itself. The runtime tracks the actual driver promises and keeps read admission occupied while sibling SQL finishes after an early Promise.all rejection. A JavaScript rejection alone does not free database capacity. These injected reads use autocommit statements; transactions and reserved connections are rejected at this read boundary.
The statement budget and pool admission bound database work, but do not promise one fixed end-to-end latency or a snapshot across multiple statements. Forced client shutdown can precede PostgreSQL detecting a disconnect; the server-side statement timeout remains the bound for unfinished SQL. Socket receive deadlines are not route-execution deadlines.
Anonymous HTTP And Logs
Section titled “Anonymous HTTP And Logs”CORS permits any origin with credentials disabled. Allowed methods are GET, HEAD, and OPTIONS; preflight accepts If-None-Match. Responses expose ETag, Retry-After, Age, and X-Request-ID. Successful resource reads implement conditional GET/HEAD and a 60-second cache lifetime. Operational routes, preflight and errors retain Cache-Control: no-store.
The limiter uses the socket peer. IPv4-mapped addresses share their IPv4 bucket; IPv6 peers share a /64. Missing or invalid peers share an unknown bucket. Forwarded address headers are ignored. Behind a proxy this deliberately groups traffic by the proxy peer until a reviewed deployment defines origin protection and exact proxy trust rules. This local mode is not sufficient proof of edge behavior.
Exhausted client tokens return 429 with a retry delay. Full identity storage and exhausted request/read admission return 503; active counters are never evicted to admit fresh identities. Retry delays are guidance, not a reservation. Rejections and readiness failures retain the public error envelope, CORS headers and no-store policy.
The rate limiter is a token bucket per normalized client identity in one server process. With the initial settings, a new client can spend up to 60 tokens immediately; each attempt allowed by the limiter spends one token, and elapsed time replenishes one token per second up to the 60-token ceiling. Once empty, a client needs time to earn another token. Refill is calculated when a request arrives, including fractional tokens, so no refill timer or background job is needed. Requests that subsequently fail validation and browser preflight also consume tokens.
The identity store is separately capped at 10,000 buckets. Requests examine at most 64 oldest entries and remove buckets idle for five minutes; rejected attempts count as activity too. If the store is still full, a new identity receives 503 while existing identities retain their current allowance. Evicting an active bucket would let it return with a fresh burst, so the limiter deliberately refuses new entries instead. The idle-expiry configuration must allow even a depleted bucket to refill before removal. These are process-local controls, so restarting the server resets them; coordination across instances remains a later scaling decision.
Each request gets a server-generated ID and one structured completion record: operation label, normalized method, status, duration, safe error type, active HTTP count, and limiter occupancy. Records omit arbitrary paths/queries, IP addresses, headers, SQL, error stacks and credentials. A log-sink failure does not change the response. The current executable writes JSON to stdout; deployment log retention, backpressure, aggregation and alerts remain operator design work. The database runtime separately exposes bounded read/statement counters through its internal getStatus() method.
Central Error Handling
Section titled “Central Error Handling”The public service follows the main backend’s error ownership: validators, protection middleware and handlers throw named application errors at the source; the global Hono error handler normalizes, observes and serializes them. src/utils/errors.ts owns the error classes, normalization and explicit public envelope projection. src/http/errors.ts owns HTTP translation, including Allow and Retry-After. Rate limiting, capacity rejection, invalid request/preflight, readiness and unknown routes all use that path. Source modules do not build error responses themselves.
Normalization trusts application error instances rather than arbitrary code, name or type properties on thrown objects. Unexpected errors become a generic 500; database admission signals become 503. Original errors stay attached as causes for server-side diagnostics. They never enter the serialized public envelope. This preserves the public service’s intentional message policy rather than copying the main backend’s current unexpected-message exposure or its localized envelope metadata.
Application composition accepts optional errorObservers, mirroring the main backend’s central observation seam. Each named observer receives the normalized error and request ID and owns redaction before exporting any diagnostic details. Synchronous throws and returned promise rejections are contained; responses do not wait for observers. The executable currently registers no domain-specific observers and continues to emit only safe completion records. Future monitoring can connect here without adding catches or logging to each handler or shared query.
createPublicAPIRequestLifecycle wraps protection and routes. Hono therefore finishes translating an error before the outer middleware records completion. Protection releases its admission slot in its own finally and supplies the resulting counters. Combining rejection and completion logging in one middleware would let its finally run before its own error reaches Hono, producing a stale status or missing error classification.
Readiness failures retain this flow too: the runtime coalesces concurrent SQL probes and clears the shared promise after either outcome. SQL errors reject with their original cause intact; the readiness handler wraps them in a safe ServiceUnavailableError. A stopped runtime reports false, which the handler also translates through a thrown 503 error. Probe failures do not discard the evidence needed by a central observer.
OpenAPI And Verification
Section titled “OpenAPI And Verification”Zod public schemas and the Hono operation declarations generate the OpenAPI 3.1 document offline:
pnpm wavemap -- docs public-api generatepnpm wavemap -- docs public-api checkThe API-owned generator writes apps/wavemap-public-api/dist/openapi.json as an ignored intermediate. The public CLI’s generate route copies it to the reviewed versioned asset at apps/wavemap-docs/public/public-api/v1/openapi.json; check regenerates and rejects drift. Never hand-edit either artifact. The reference page renders that versioned asset directly. Complete operation declarations live in the resource-grouped files under src/http/operations, checked with satisfies DescribeRouteOptions. Each named handler tuple attaches its declaration through describeRoute; request validation and execution remain in the handler. Declarations compose canonical response schemas through resolver, examples from src/constants/readExamples.ts and shared response/header definitions from src/http/operationMetadata.ts. Field descriptions, allowlists and limits remain with the shared schemas; hono-openapi validators register the same query/path schemas used at runtime. Add future operation declarations to their resource module while keeping Hono integration app-owned.
The artifact includes nineteen registered GET operations (seventeen resource reads and two operational reads), query descriptions/allowlists (including incremental controls), parent parameters, safe failures, conditional headers, bodyless 304 and illustrative selected-field responses. Resource operations describe shared GET/HEAD semantics. Tests validate generated examples against their effective response schemas and verify cache/error headers and absent 304 content. Hosting this reference remains a separately approved publication workflow.
Focused checks:
pnpm -F @wavemap/public-api exec vitest run test/app.test.ts test/events.test.ts test/errorHandling.test.tspnpm -F @wavemap/public-api typecheckpnpm -F @wavemap/public-api lintpnpm -F @wavemap/public-api exec vitest run --config vitest.db.config.ts test/database/events.test.tsOrdinary root tests include the hermetic public-app and data-access tests. Real PostgreSQL proof stays in the separate uncached test:db lane, selected by the database assurance CI owner. That owner builds the executable and seed prerequisites and bootstraps the shared disposable baseline independently of the main suite. Its assertions check allowed reads and denied DML/DDL/private access with read-only mode disabled, cancellation/recovery, retained admission, connection caps, readiness and shutdown. In-memory HTTP tests prove a different boundary and do not replace it.
Update this page when route ownership, query fan-out, public projection, grant requirements, defaults, proxy topology, package watching or executable startup changes. The Development CloudFront/origin and reader boundaries are documented in Public API Runtime; launch capacity, documentation publication and reuse terms remain separate release checkpoints. Consumer integration belongs in Reading The Public API.