Technical reference for the SPEAR (Syriaca Prosopographical Event Analysis and Research) web application. This document describes the actual implementation as it exists in the codebase: architecture, data flow, SPARQL query construction, caching, the Neptune/API Gateway backend, and operational guidance.
Scope note. This document reflects the code in this repository. Where the top-level
README.mddisagrees with the code, the code is authoritative and the discrepancies are called out in Appendix A.
SPEAR is a static, framework-free single-page application written in vanilla
ES modules. There is no build step, bundler, or package.json. Files are served
as-is over HTTP and executed directly by the browser.
Browser (ES modules)
│
├── browse.html / dev.html ← entry HTML, boots the app
│
├── mode.js ← mode router / mount lifecycle
│
├── modes/{person,event,relation}.js ← per-mode UI + fetch + render
│
├── {person,event,relation}/search.js ← SPARQL query builders + fetchers
│
├── menu.js + list.js ← facet menu loading + localStorage cache
│
├── filter.js ← shared filter state + URL sync helpers
│
└── utils/{cleanUi.js,url.js} ← display + URL helpers
│
▼
SPARQL HTTP endpoint (https://sparql.vanderbilt.edu/sparql)
│
▼
AWS API Gateway ──(VPC Link → NLB)──► Amazon Neptune cluster
(writer + reader, db.t3.medium)
Data strategy: the app is a hybrid. Default and "simple" views are served from bundled JSON snapshots; only complex/live facet combinations issue live SPARQL queries to Neptune. This is a deliberate load-reduction measure.
browse.html— production entry.dev.html— development entry (same wiring; used for local testing).
Both end with an ES module script that imports the mode router and registers the three modes:
<script type="module">
import { boot } from './mode.js';
import personMode from './modes/person.js';
import eventMode from './modes/event.js';
import relationMode from './modes/relation.js';
boot({
registry: { person: personMode, event: eventMode, relation: relationMode },
defaultType: 'person'
});
</script>boot({ registry, defaultType }) owns the application lifecycle:
- Holds shared
state = { type, filters }. - Reads/writes the active mode from the
?type=URL parameter (readTypeFromUrl/writeTypeToUrl), enabling shareable, back-button-aware URLs viahistory.pushState/popstate. togglePanels(type)shows/hides the#personResults/#eventResults/#relationResultspanels.wireSidebarChromehandles collapse toggles and dispatches aclearSectioncustom event that the active mode listens for.mount(type)renders the mode's sidebar (mode.sidebar(state)), wires it (mode.bind(...)), then callsrunSearch().runSearch()callsmode.fetch(state)→mode.render(rows, state), with special handling: a falsy return aborts silently, and a{ error, message }return renders a user-facing facet error instead of results.
Each mode module (modes/*.js) exports a default object implementing:
| Method | Purpose |
|---|---|
sidebar(state) |
returns the sidebar HTML string for the mode |
bind(root, state, runSearch) |
wires event listeners, loads facet menus, initializes state |
fetch(state) |
returns result rows (from JSON and/or live SPARQL) |
render(rows, state) |
renders rows into the mode's results panel |
There are three modes, each backed by its own search.js.
- Result shape: persons (deduplicated), enriched with labels + description.
- JSON-first: default view and name-only searches are served from
person/person.json. Live SPARQL (fetchData) is only called for complex facet combinations. - Query builder:
buildMultiFilterQuery(state)resolves person identity in an inner subquery insideGRAPH <https://spear-prosop.org>, then enriches with labels/description fromGRAPH <http://syriaca.org/persons#graph>. This follows the "resolve person first, then enrich" pattern documented inperson/README.md. - UI fields consumed by
render:person,label_en,label_syr,description(only these four).
- Result shape: event factoids.
- JSON pre-filtering: name/gender/occupation filters are applied
client-side against
event/person.json, but the fetch path always ends by calling the livefetchEventFactoids(...). - Query builder:
buildEventFactoidQuery(state). With no facets selected it emits the default query across all three sources (?event sp:event-keyword/spr:reference-URL ?factoid) withLIMIT 20000. - UI fields consumed by
render:uri(factoid),description,source(mapped to a label),eventKeyword.
- Result shape: relationship factoids.
- JSON-first: default view + source/name/gender/occupation combinations read
from
relation/filtered_relation_factoids.json/relation/all_person_relation_factoids.jsonwith client-side filtering. Live SPARQL (fetchData/fetchRelationFactoids) is the fallback. - Query builder:
buildRelationFactoidQuery(state)anchors on?statementNode spr:reference-URL ?factoidand joins?factoid spr:part-of-series ?source(defaulting to the three known sources). - UI fields consumed by
render:uri(factoid),description,source,relationship.
Dead code: the top-level
search.js(which contains two same-namedbuildMultiFilterQueryfunctions andfetchFactoidsByMultiType/fetchFactoidsWithFilters) is not imported anywhere — the only reference infilter.jsis commented out. It does not run. Treat it as removable.
- Filter state is a set of
Sets, one per facet (selectedEventKeywords,selectedRelationshipKeywords,selectedEthnicityKeywords,selectedGenderKeywords,selectedPlaceKeywords,selectedOccupationKeywords,selectedSourceKeywords), plus anamestring anduncertainty. FILTER_MAPmaps each facet to its state key and URL parameter key.stateToUrlParams(state)serializes facet selections intoURLSearchParams; the mode files call awriteFilterParamsToUrl(state)on each change so the URL is always shareable.- Source label display is handled by a small
sourceLabelslookup +getSourceLabel(uri)(falls back toprettifyUri).
The three sources are:
| URI | Label |
|---|---|
https://spear-prosop.org/letters-severus |
Letters of Severus of Antioch |
https://spear-prosop.org/lives-eastern-saints |
Lives of the Eastern Saints |
https://spear-prosop.org/chronicle-edessa |
Chronicle of Edessa |
Selecting all three is treated as "no source filter"; selecting a strict subset
(size > 0 && size < 3) applies a source constraint.
| Graph | Contents |
|---|---|
https://spear-prosop.org |
factoids, event links, statement nodes, provenance |
http://syriaca.org/persons#graph |
multilingual labels (en/syr), descriptions |
http://syriaca.org/geo#graph |
place labels (used by the place menu) |
| Prefix | Namespace | Role |
|---|---|---|
swdt: |
http://syriaca.org/prop/direct/ |
direct factoid triples |
sp: |
http://syriaca.org/prop/ |
statement-node predicates |
sps: |
http://syriaca.org/prop/statement/ |
statement-level values |
spr: |
http://syriaca.org/prop/reference/ |
provenance / reference URLs |
spq: |
http://syriaca.org/prop/qualifier/ |
qualifiers (e.g. certainty) |
schema: |
http://schema.org/ |
descriptions |
rdfs: |
http://www.w3.org/2000/01/rdf-schema# |
labels |
skos: |
http://www.w3.org/2004/02/skos/core# |
taxonomy collections |
- Facet blocks are built into an array and joined into the
WHEREclause. - Multi-value facets use
VALUES ?var { <uri> <uri> ... }. - Relationship facets rewrite taxonomy URIs to property URIs
(
.replace('/taxonomy/', '/prop/')) and bind a variable predicate (?person ?relationship ?stmt). - Uncertainty is an exclusion filter:
FILTER NOT EXISTS { ?factoid spq:certainty ?level . FILTER(LCASE(STR(?level)) IN (...)) }. - Place facets are a 4-way
UNIONover birth-place / death-place / residence / event-place. VALUESshould be placed insideGRAPHblocks — Neptune interprets them differently outside (seeperson/README.md).
These are documented so future work is grounded:
LIMIT 20000+ globalORDER BY ?factoidforces full materialize + sort before limiting. Prefer server-side paging (smallLIMIT+OFFSET, or keyset paging) and dropORDER BYwhen the UI sorts client-side.- Always-on anchors (
?statementNode spr:reference-URL ?factoid+?factoid spr:part-of-series ?source) full-scan when no selective facet is set. A "require ≥1 selective facet" guard (the commented-outonlySourceSelectedchecks) avoids the worst queries. - Variable-predicate relationship patterns defeat predicate indexing.
FILTER(LANG(...))/LCASE(STR(...))run post-match; preferLANGMATCHESand direct value comparison.- Wide
SELECT DISTINCTprojections increase dedup cost — project only whatrenderconsumes.
Facet menus are reference data (taxonomies, place lists). They are loaded by two
loaders and cached in localStorage.
- Query generators (
getEventKeywords,getRelationshipOptions,getEthnicityOptions,getPlaceOptions,getOccupationOptions,getEducationFieldsOfStudy) return SPARQL strings. populateDropdown(query, dropdownId, ...)— fills a<select>; cached viafetchWithCacheunder keydropdown_<id>.renderKeywordList(query, itemsId, listId, ...)— fills a scrollable<ul>; cached under keykeywordListV2_<itemsId>.The cache key is derived from the element id, not the query text. An earlier version keyed on the first 20 chars of
btoa(query), which collided across queries sharing a leadingPREFIX(occupations, relationships, fields-of-study), causing one list to overwrite another. TheV2prefix also bypasses stale collided entries left by the old version.fetchWithCache(key, fetchFn)— exported. 24h TTL inlocalStorage(<key>+<key>_time). Only writes on success.clearMenuCache()— purges all cache prefixes:dropdown_,keywordList_(legacy),keywordListV2_,keywordPrettyV1_,eventFactoidsV1_.
Drives the event-concept (eventKeywordItems) and ethnicity
(ethnicityKeywordItems) lists. It:
- Fetches all pages once (loops
OFFSET/LIMIT 50until a short/empty page). - Caches the combined bindings via
fetchWithCacheunderkeywordPrettyV1_<listId>. - Renders in a single pass (no per-scroll Neptune queries).
- Derives display labels by prettifying the URI tail.
fetchEventFactoids caches its mapped result rows via fetchWithCache,
keyed on a DJB2 hash of the full query text
(eventFactoidsV1_<hash> via eventCacheKey(query)). This caches the default
unfiltered event query and gives each distinct facet combination its own entry.
On a non-OK HTTP response it throws (so failures are not cached); the caller
still returns [].
| Data | Loader | Cache key prefix | Cached |
|---|---|---|---|
<select> dropdowns |
populateDropdown |
dropdown_ |
✅ |
| place / relationship / occupation lists | renderKeywordList |
keywordListV2_ |
✅ |
| event concepts / ethnicity | renderKeywordPrettyList |
keywordPrettyV1_ |
✅ |
| event factoid results | fetchEventFactoids |
eventFactoidsV1_ |
✅ |
TTL: 24h for all of the above. Menus are static reference data (safe).
Event factoid results are result data — if Neptune data is updated, cached
results can be up to 24h stale; call clearMenuCache() after a data refresh or
lower the TTL for that key if immediate freshness is required.
localStorage limit: ~5 MB per origin. Large LIMIT 20000 result sets could
approach this; if many distinct large results accumulate, a setItem may throw
QuotaExceededError. Consider a best-effort write guard if this becomes an issue.
Client → API Gateway (stage) → VPC Link → Network Load Balancer → Neptune
- Neptune cluster: writer + reader instances,
db.t3.medium, in a VPC. - API Gateway integration: points at the Neptune reader instance endpoint over TCP/HTTPS 443. It is a raw SPARQL passthrough — there is no Lambda between API Gateway and Neptune.
- Integration timeout: 15000 ms (recommended; fail fast to release Neptune slots).
neptune_query_timeout(cluster parameter group): 25000 ms. Backstop that kills long queries server-side regardless of the client.- Integration timeout (15s) intentionally sits below
neptune_query_timeout(25s): API Gateway gives up first and frees the client, while Neptune retains a hard 25s backstop.
- Stage throttling: ~10 req/sec rate, 20 burst. This is the aggregate ceiling protecting Neptune; it is well above expected real load (~12 users × ~2 req/min ≈ 0.4 req/sec).
- Recommended additional layer: per-IP rate limiting via AWS WAF so one client cannot consume the whole stage budget.
"Writer at ~10% CPU with no writers" is normal baseline on db.t3.medium
(background housekeeping + possibly reads). CPU is not a write indicator.
- To confirm writes: check CloudWatch
VolumeWriteIOPs(≈0 means no writes). - The writer can also serve reads; the cluster endpoint always resolves to the writer, so ensure clients/scripts target the reader endpoint.
Because the endpoint is a public raw passthrough:
- Block writes: nothing structurally prevents
INSERT/DELETE/LOAD/DROPfrom reaching Neptune if a request can hit a writer-capable endpoint. Confirm no API Gateway route targets the cluster/writer endpoint. Strongest fix: a Lambda proxy that allows onlySELECT/ASK, capsLIMIT, and sets a per-query timeout — or a read-only IAM database-auth constraint on Neptune. - Keep Neptune private: it should sit in private subnets, reachable only via the NLB/VPC Link, never publicly routable.
- WAF: attach rate-based rules + managed rule groups + request-size limits.
- Caching: API Gateway response caching / CloudFront in front further sheds load for near-static data.
Alarm on:
MainRequestQueuePendingRequests(queue depth; current alarm:> 1for one datapoint in 5 min).- API Gateway 4xx/5xx and latency.
- Neptune
VolumeWriteIOPs,SPARQLRequestsPerSec, CPU credit balance.
No build step. Serve the folder over HTTP (ES modules + localStorage do not
work under file://):
python3 -m http.server 8000
# then open:
# http://localhost:8000/browse.html (production entry)
# http://localhost:8000/dev.html (dev entry)Alternatives: npx serve, or the VS Code "Live Server" extension.
The SPARQL endpoint must allow the local origin
(http://localhost:8000). If it does not, browser fetches fail with a CORS
error even when the query is valid — this is an endpoint-config issue, not a
code bug.
- Open DevTools before loading the page.
- Network (filter
sparql): requests fire on first load, then should not re-fire on reload. - Console:
fetchWithCachelogs[CACHE] Fetching new data for <key>(cold) vs[CACHE] Using cached data for <key>(warm). - Application → Local Storage: confirm one key per data set, e.g.
keywordListV2_occupationKeywordItems,keywordPrettyV1_eventKeywordItems,eventFactoidsV1_<hash>. - To force a cold start, clear Local Storage (or call
clearMenuCache()), then reload. A hard reload alone does not clearlocalStorage.
utils/cleanUi.js— display helpers:cleanPunctuationSpacing,uriDisplayString,toAggregateUri,deduplicateFactoids.utils/url.js— URL parameter helpers used alongsidefilter.js.
Aggregate person/place/taxonomy pages are static HTML under aggregate/
(aggregate/person/*.html, aggregate/place/*.html,
aggregate/taxonomy/*.html). toAggregateUri links results to these pages.
spear/
├── browse.html # production entry
├── dev.html # development entry
├── mode.js # mode router / boot
├── filter.js # shared filter state + URL sync
├── menu.js # facet menu queries + populateDropdown/renderKeywordList + cache
├── list.js # renderKeywordPrettyList (event concepts, ethnicity) + cache
├── modes/
│ ├── person.js # person UI + fetch + render
│ ├── event.js # event UI + fetch + render
│ └── relation.js # relation UI + fetch + render
├── person/
│ ├── search.js # person query builder + fetchData
│ ├── person.json # bundled person snapshot
│ └── README.md # person-search SPARQL notes + pitfalls
├── event/
│ ├── search.js # event query builder + fetchEventFactoids (+ cache)
│ ├── person.json # bundled event/person snapshot
│ └── README.md
├── relation/
│ ├── search.js # relation query builder + fetchers
│ ├── *.json # bundled relation factoid snapshots
│ └── batch_query_*.sh # batch export scripts (rate-limited)
├── utils/
│ ├── cleanUi.js
│ └── url.js
├── aggregate/ # static person/place/taxonomy HTML pages
└── CTS/ # CTS resolver (XQuery) — separate subsystem