Reporting & Analytics (REST + SQL layer)
Reporting & Analytics API
What it is. Reporting Sync is WIP's cross-cutting reporting and analytics layer. A background sync worker consumes NATS events for document, template, terminology, term, and term-relation changes and materializes them as flattened PostgreSQL table views — one doc_<value> table per template per namespace, plus fixed terminologies / terms / templates / term_relations metadata tables — so consumers can query WIP data with SQL instead of the document stores' own APIs (reporting_sync/worker.py, reporting_sync/schema_manager.py). A separate batch-sync path backfills or rebuilds those tables from the REST APIs of Def-Store, Template-Store, and Document-Store (reporting_sync/batch_sync.py). On top of the table views the service exposes ad-hoc read-only SQL querying, unified cross-entity search (terminology/term/template/document/file), outgoing/incoming reference validation, an aggregated referential-integrity check, an activity feed, and CSV export (reporting_sync/main.py).
Reaching it. Base path /api/reporting-sync (APIRouter(prefix="/api/reporting-sync"), main.py). /health is additionally exposed unprefixed, for direct container probes rather than through the API prefix. The interactive schema is served unprefixed at /docs (FastAPI default), reachable only when addressing the service directly. The service declares an X-API-Key security requirement in its OpenAPI contract (declare_api_key_security(app), main.py) — every route in this doc expects that header. See also: Auth & Access; Routing & Ingress.
Conventions that apply.
- Namespace scoping, exercised throughout: every WIP namespace maps to its own PostgreSQL schema. Reporting tables live inside that schema under plain, unqualified names (
doc_<template value>for documents;terminologies/terms/templates/term_relationsfor metadata) — the same template value in two namespaces produces two distinct tables, never one shared table disambiguated by a namespace column (schema_manager.py: SchemaManagerdocstring). Most endpoints below takenamespaceas an explicit, non-defaulted parameter rather than assuming one. - Rate limiting:
setup_rate_limiting(app)readsWIP_RATE_LIMIT, defaulting to 40000/minute (main.py). - This store does not expose bulk-array write endpoints with per-item results — batch-sync triggers return a single job or a single per-table result object, not the platform's array-in/per-item-results bulk-first shape. See API Conventions for where that convention does apply.
Endpoints
Health & status
GET /health
- Purpose. Container-level liveness/readiness probe (unprefixed, for direct container health checks).
- Request. No parameters.
- Response.
200HealthResponse:status("healthy"when both NATS and Postgres are connected,"degraded"when exactly one is,"unhealthy"when neither),service,version,nats_connected,postgres_connected,details(nats_url,postgres_host,stream_name). The Postgres flag is re-probed live with aSELECT 1on every call, not just read from a cached startup flag. - Errors. None beyond the standard 5xx if the process itself is unhealthy.
- Example.
curl http://reporting-sync:8005/health{"status": "healthy", "service": "wip-reporting-sync", "version": "0.1.0", "nats_connected": true, "postgres_connected": true, "details": {"nats_url": "nats://nats:4222", "postgres_host": "postgres", "stream_name": "WIP_EVENTS"}}
GET /api/reporting-sync/health
- Purpose. Same handler as
GET /healthabove, reachable through the API prefix for callers going through the router. - Request / Response / Errors. Identical to
GET /health. - Example.
curl -H "X-API-Key: $KEY" https://.../api/reporting-sync/health
GET /api/reporting-sync/status
- Purpose. Current sync-worker status.
- Request. No parameters.
- Response.
200SyncStatus:running,connected_to_nats,connected_to_postgres,last_event_processed,events_processed,events_failed,tables_managed.connected_to_nats/connected_to_postgresare refreshed from live state on every call (NATS viais_connected, Postgres via a 1-second-timeoutSELECT 1) — this endpoint is the final authority, not a value latched at startup. - Errors. None distinctive.
- Example.
curl -H "X-API-Key: $KEY" https://.../api/reporting-sync/status{"running": true, "connected_to_nats": true, "connected_to_postgres": true, "last_event_processed": "2026-07-09T10:02:11Z", "events_processed": 4821, "events_failed": 3, "tables_managed": 27}
Monitoring & metrics
GET /api/reporting-sync/metrics
- Purpose. Comprehensive service metrics: uptime, connection status, event throughput, consumer lag, latency percentiles, per-template stats, error breakdown.
- Request. No parameters.
- Response.
200MetricsResponse:started_at,uptime_seconds,nats_connected,postgres_connected,events_processed,events_failed,events_per_second,consumer_info(ConsumerInfo | null),processing_latency(LatencyStats:sample_count,min_ms,max_ms,avg_ms,p50_ms,p95_ms,p99_ms— computed from a sliding window of up to 1000 samples),template_stats(list ofPerTemplateStats),errors_by_type(dict of error-type → count). - Errors. None distinctive.
- Example.
curl -H "X-API-Key: $KEY" https://.../api/reporting-sync/metrics{"started_at": "2026-07-08T09:00:00Z", "uptime_seconds": 91234.5, "nats_connected": true, "postgres_connected": true, "events_processed": 4821, "events_failed": 3, "events_per_second": 1.2, "consumer_info": {"stream_name": "WIP_EVENTS", "consumer_name": "reporting-sync-durable", "pending_messages": 0, "pending_bytes": 0, "delivered_messages": 4824, "ack_pending": 0, "redelivered": 1, "last_delivered": null}, "processing_latency": {"sample_count": 1000, "min_ms": 1.2, "max_ms": 340.1, "avg_ms": 18.4, "p50_ms": 12.0, "p95_ms": 55.3, "p99_ms": 120.7}, "template_stats": [{"template_value": "patient", "table_name": "doc_patient", "documents_synced": 512, "documents_failed": 0, "last_sync_at": "2026-07-09T10:02:11Z", "last_error": null, "last_error_at": null}], "errors_by_type": {}}
GET /api/reporting-sync/metrics/consumer
- Purpose. NATS JetStream consumer detail only (pending/ack-pending/redelivered/delivered counts).
- Request. No parameters.
- Response.
200ConsumerInfo | null—nullwhen the service has no JetStream context (NATS not connected). - Errors. None distinctive.
- Example.
curl -H "X-API-Key: $KEY" https://.../api/reporting-sync/metrics/consumer
GET /api/reporting-sync/alerts
- Purpose. Current alert configuration plus active and recently resolved alerts.
- Request. No parameters.
- Response.
200AlertsResponse:config(AlertConfig),active_alerts(list ofAlert),resolved_alerts(list ofAlert, capped at the last 100 resolved).AlertConfig:enabled,check_interval_seconds(default 30),thresholds(AlertThresholds:queue_lag_warning=100,queue_lag_critical=1000,error_rate_warning=5.0,error_rate_critical=20.0,stall_warning_seconds=300,stall_critical_seconds=600),webhook_url,webhook_headers.Alert:alert_id,alert_type(queue_lag|error_rate|processing_stalled|connection_lost),severity(info|warning|critical),message,triggered_at,resolved_at,details. - Errors. None distinctive.
- Example.
curl -H "X-API-Key: $KEY" https://.../api/reporting-sync/alerts
PUT /api/reporting-sync/alerts/config
- Purpose. Replace the alert configuration.
- Request. Body: a full
AlertConfigobject (not a partial patch — the handler assigns it wholesale). - Response.
200theAlertConfigas stored. - Errors.
422on a body that doesn't validate asAlertConfig. Configuration is held in memory only — it does not survive a process restart (metrics.py: MetricsCollector._alert_config). - Example.
curl -X PUT -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"enabled": true, "check_interval_seconds": 60, "thresholds": {"queue_lag_warning": 200, "queue_lag_critical": 2000, "error_rate_warning": 5.0, "error_rate_critical": 20.0, "stall_warning_seconds": 300, "stall_critical_seconds": 600}, "webhook_url": "https://hooks.example/alerts", "webhook_headers": {}}' \ https://.../api/reporting-sync/alerts/config
POST /api/reporting-sync/alerts/test
- Purpose. Force an immediate alert check (rather than waiting for the periodic background loop) and return the result.
- Request. No parameters.
- Response.
200{"checked": true, "nats_connected": bool, "postgres_connected": bool, "consumer_pending": int | null, "new_alerts": [...], "active_alerts": [...]}. - Errors. None distinctive.
- Example.
curl -X POST -H "X-API-Key: $KEY" https://.../api/reporting-sync/alerts/test
Table & schema introspection
GET /api/reporting-sync/table-name
- Purpose. Resolve the reporting table name for a
(namespace, template_value)pair without constructing it by hand. - Request. Query:
namespace(required),template_value(required). - Response.
200{"namespace", "template_value", "schema", "table_name", "qualified_name", "exists"}—existsreports whether the table has actually been materialized (tables are created lazily on first sync). - Errors.
503if PostgreSQL isn't connected.400ifnamespace/template_valuefails identifier validation (a"or NUL byte, or over PostgreSQL's 63-byte identifier limit). - Note. A template's optional
reporting.table_namecosmetic override is not reflected here yet — this always returns the defaultdoc_<value>form (main.py: resolve_table_namedocstring). - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/table-name?namespace=clinicA&template_value=patient"{"namespace": "clinicA", "template_value": "patient", "schema": "clinicA", "table_name": "doc_patient", "qualified_name": "\"clinicA\".\"doc_patient\"", "exists": true}
GET /api/reporting-sync/schema/{template_value}
- Purpose. Column definitions and row count for a template's reporting table.
- Request. Path:
template_value. Query:namespace(required — the table lives inside that namespace's schema, and the same template value can exist in several namespaces). - Response.
200{"namespace", "template_value", "schema", "table_name", "qualified_name", "columns": [{"name","type","nullable","default"}], "row_count"}. - Errors.
503if PostgreSQL isn't connected.400on an unsafe namespace identifier.404if the resolved table doesn't exist. - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/schema/patient?namespace=clinicA"
Batch sync
POST /api/reporting-sync/sync/batch/terminologies
- Purpose. Batch-sync all terminologies from Def-Store into the reporting
terminologiestable. - Request. Query:
namespace(required, no default),page_size(default 100). - Response.
200{"status": "completed", "table": "terminologies", "synced": int, "failed": int, "total": int}. - Errors.
503if the batch-sync service isn't available (Postgres not connected at startup). - Example.
curl -X POST -H "X-API-Key: $KEY" "https://.../api/reporting-sync/sync/batch/terminologies?namespace=clinicA"
POST /api/reporting-sync/sync/batch/terms
- Purpose. Batch-sync all terms (iterating every terminology) into the reporting
termstable. - Request / Response / Errors. Same shape as the terminologies sync above (
"table": "terms").
POST /api/reporting-sync/sync/batch/term_relations
- Purpose. Batch-sync all active term-relations from Def-Store's ontology API into the reporting
term_relationstable. - Request. Query:
namespace(required, no default),page_size(default 100). - Response.
200{"status": "completed", "table": "term_relations", "synced", "failed", "total"}. - Errors.
503if the batch-sync service isn't available. Term-relations only otherwise sync from live NATS events, so a rebuilt reporting database loses all historical relations until this endpoint (or the startup backfill) runs.
POST /api/reporting-sync/sync/batch/templates
- Purpose. Batch-sync template metadata (status, version,
extends) from Template-Store into the reportingtemplatestable — the rebuild/backfill path, since the live sync path only writes on template events. - Request / Response / Errors. Same shape (
"table": "templates").
POST /api/reporting-sync/sync/batch
- Purpose. Trigger a document batch-sync job for every template with
sync_enabledtrue. - Request. Query:
force(defaultfalse),page_size(default 100). - Response.
200list[BatchSyncResponse], one per template:{"job_id", "template_value", "status", "message"}. Terminologies and terms are synced first as reference data, then a job is started per template with a small delay between starts. - Errors.
503if the batch-sync service isn't available. - Example.
curl -X POST -H "X-API-Key: $KEY" "https://.../api/reporting-sync/sync/batch?force=false"
GET /api/reporting-sync/sync/batch/jobs
- Purpose. List all tracked batch-sync jobs.
- Request. No parameters.
- Response.
200list[BatchSyncJob]:job_id,template_value,status(pending|running|completed|failed|cancelled),started_at,completed_at,total_documents,documents_synced,documents_failed,current_page,error_message. - Errors.
503if the batch-sync service isn't available.
GET /api/reporting-sync/sync/batch/jobs/{job_id}
- Purpose. Get one batch-sync job by ID.
- Request. Path:
job_id. - Response.
200BatchSyncJob. - Errors.
404if the job isn't found.503if the batch-sync service isn't available.
POST /api/reporting-sync/sync/batch/{template_value}
- Purpose. Trigger a batch-sync job for one template — fetches every document for that template from Document Store and syncs it to PostgreSQL.
- Request. Path:
template_value. Query:force(defaultfalse),page_size(default 100, must be 10–1000). - Response.
200BatchSyncResponse. - Errors.
400ifpage_sizeis outside 10–1000.503if the batch-sync service isn't available. - Example.
curl -X POST -H "X-API-Key: $KEY" "https://.../api/reporting-sync/sync/batch/patient?force=true&page_size=200"
DELETE /api/reporting-sync/sync/batch/jobs/{job_id}
- Purpose. Cancel a running batch-sync job.
- Request. Path:
job_id. - Response.
200{"status": "cancelled", "job_id"}if it was running,{"status": "not_running", "job_id"}otherwise — cancelling an unknown or already-finished job is not an error. - Errors.
503if the batch-sync service isn't available.
DELETE /api/reporting-sync/sync/batch/jobs
- Purpose. Clear completed/failed/cancelled jobs from memory.
- Request. No parameters.
- Response.
200{"cleared": <count>}. - Errors.
503if the batch-sync service isn't available.
Integrity check
GET /api/reporting-sync/health/integrity
- Purpose. Aggregate referential-integrity results from Template Store's and Document Store's own
/health/integrityendpoints into one report. - Request. Query:
template_status(optional),document_status(optional filter:active/inactive/archived),template_limit(default0in the actual signature — see the code-findings note below),document_limit(default0, same note),check_term_refs(defaulttrue),recent_first(defaultfalse). - Response.
200AggregatedIntegrityResult:status(healthy|warning|error|partial),checked_at,services_checked,services_unavailable,summary(IntegritySummary:total_templates,total_documents,documents_checked,templates_with_issues,documents_with_issues,orphaned_terminology_refs,orphaned_template_refs,orphaned_term_refs,inactive_refs),issues(list ofIntegrityIssue:type,severity,source,entity_id,entity_value,field_path,reference,message).statusispartialwhen some services were unreachable but at least one responded,errorwhen none responded or any issue hasseverity="error",warningif any issue is a warning, elsehealthy. - Errors. No hard failure when a peer service is unreachable — that service is added to
services_unavailableinstead and the aggregatestatusdegrades topartial/error. The Document Store call's timeout scales withdocument_limit(up to 30 minutes atdocument_limit=0, i.e. "all"). - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/health/integrity?document_limit=5000"
Search & activity
POST /api/reporting-sync/search
- Purpose. Unified search across terminologies, terms, templates, documents, and files, with independent per-type pagination.
- Request. Body
SearchRequest(rejects unknown fields):query(required, min length 1),types(optional list, default all five types),namespace,status,template(restricts document search to one template by value),mode("auto"default |"fts"|"substring"),include_inactive(defaultfalse),snippet_format("html"default |"text"),page(default 1),page_size(1–100, default 50),limit(deprecated alias forpage_sizewhenpage=1). Query paramsnamespace,status,templateare also accepted and, when present, override the same field in the body. - Response.
200SearchResponse:query,mode,results(dict keyed by entity type — only types actually visited appear — each aSearchTypeResults:items(list ofSearchResult),total,page,page_size,pages),total(sum across types).SearchResult:type,id,value,label,status,description,updated_at,score(FTS hits only),snippet(FTS hits only). - Errors.
503if the search service isn't available.422on a body with an unrecognized field (strict request model) orqueryshorter than 1 character. Per-type result sets are capped at 1000 items in memory; beyond that a query needs refining rather than paginating further. - Example.
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"query": "diabetes", "types": ["document"], "mode": "auto"}' \ "https://.../api/reporting-sync/search?namespace=clinicA"{"query": "diabetes", "mode": "auto", "results": {"document": {"items": [{"type": "document", "id": "doc-123", "value": "PATIENT", "label": "PATIENT document", "status": "active", "description": "Matched in doc_patient", "updated_at": "2026-07-01T00:00:00Z", "score": 0.42, "snippet": "history of <b>diabetes</b>"}], "total": 1, "page": 1, "page_size": 50, "pages": 1}}, "total": 1}
GET /api/reporting-sync/activity/recent
- Purpose. Recent-activity timeline aggregated across entity types.
- Request. Query:
types(comma-separated, optional — valid valuesterminology,term,template,document; the code does not fetch recent files here despiteSearchServicesupporting afiletype),limit(default 50). - Response.
200ActivityResponse:activities(list ofActivityItem:type,action(created|updated|deleted|deprecated),entity_id,entity_value,entity_label,timestamp,user,version,details),total. - Errors.
503if the search service isn't available.limitoutside 1–200 is not rejected — it is silently replaced with the default 50 rather than returning422. - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/activity/recent?types=document,template&limit=20"
GET /api/reporting-sync/references/term/{term_id}/documents
- Purpose. Find documents whose
term_referencesinclude the given term. - Request. Path:
term_id. Query:limit(default 100). Requires PostgreSQL — walks everydoc_*table across every namespace schema for aterm_references_jsoncolumn and does a JSON text search for the term ID. - Response.
200TermDocumentsResponse:term_id,documents(list ofDocumentReference:document_id,template_id,template_value,field_path,status,created_at),total. - Errors. No error when PostgreSQL is unavailable — returns an empty result set (
documents: [], total: 0) rather than503.limitoutside 1–1000 is silently replaced with 100 rather than rejected. - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/references/term/trm-abc/documents"
GET /api/reporting-sync/entity/{entity_type}/{entity_id}/references
- Purpose. Fetch an entity and validate every reference it makes outward (template → terminology/parent template, document → template/terms, term → terminology).
- Request. Path:
entity_type(one ofdocument,template,terminology,term—fileis accepted by the sibling "referenced-by" endpoint but not here),entity_id. - Response.
200EntityReferencesResponse:entity(EntityDetails—entity_type,entity_id,entity_value,entity_label,entity_status,version,created_at,updated_at,data,references(list ofEntityReference:ref_type,ref_id,ref_value,ref_label,field_path,status(valid|broken|inactive),error),valid_refs,broken_refs,inactive_refs) ornull,error(set instead ofentitywhen the lookup itself fails, e.g. entity not found upstream). - Errors.
400ifentity_typeisn't one of the four valid values.503if the search service isn't available. An upstream 404 from the owning store is surfaced asentity: null, error: "<Type> not found: <id>"with an overall200, not propagated as a 404. - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/entity/document/doc-123/references"
GET /api/reporting-sync/entity/{entity_type}/{entity_id}/referenced-by
- Purpose. Find entities that reference the given entity (the reverse of the endpoint above).
- Request. Path:
entity_type(document,template,terminology,term— accepted, thoughdocumentalways returns an empty list since documents aren't referenced by other entities in this model),entity_id. Query:limit(ge=1, le=500, default 100 — validated by FastAPI, unlike the silent-clamp endpoints above). - Response.
200ReferencedByResponse:entity_type,entity_id,entity_value,entity_label,referenced_by(list ofIncomingReference:entity_type,entity_id,entity_value,entity_label,entity_status,field_path,reference_type— one ofuses_template,extends,template_ref,terminology_ref,term_ref,file_ref),total,error. - Errors.
400ifentity_typeisn't one of the four valid values.422iflimitis outside 1–500.503if the search service isn't available. - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/entity/template/tpl-patient/referenced-by?limit=50"
Query (cross-template joins)
GET /api/reporting-sync/tables
- Purpose. List available reporting tables across every namespace schema, optionally filtered to one namespace or one bare table name.
- Request. Query:
namespace(optional, restricts to one schema),table_name(optional, returns full column detail for that bare table name instead of a summary). - Response.
200{"tables": [...]}. Each entry:namespace,name,template_value(parsed from the table name fordoc_*tables,nullotherwise),qualified_name,row_count, and eithercolumn_count(summary mode) orcolumns([{"name","type","nullable"}], whentable_nameis given). Onlydoc_*tables and the four fixed metadata tables (terminologies,terms,term_relations,templates) are listed — other PostgreSQL tables in a namespace schema are filtered out, andpublic/pg_catalog/information_schema/pg_toastare never treated as namespaces. - Errors.
503if PostgreSQL isn't connected.404iftable_nameis given and matches nothing. - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/tables?namespace=clinicA"
POST /api/reporting-sync/query
- Purpose. Execute an arbitrary read-only SQL query against the reporting database — the ad-hoc SQL/cross-template-join surface.
- Request. Body
ReportQuery:sql(required),params(positional bind values, default[]),timeout_seconds(1–300, default 30),max_rows(1–50000, default 1000),namespace(optional — when set, the connection'ssearch_pathis set to that namespace's schema for the duration of the query, so unqualified table names likedoc_patientresolve there; omit and schema-qualify instead for cross-namespace queries). - Response.
200{"columns": [...], "rows": [...], "row_count": int, "truncated": bool}. The query is wrapped asSELECT * FROM (<sql>) _q LIMIT <max_rows + 1>and run withdefault_transaction_read_only = on;truncatedistruewhen the result was cut atmax_rows. - Errors.
400if the SQL containsINSERT,UPDATE,DELETE,DROP,ALTER,CREATE,TRUNCATE,GRANT, orREVOKEat a word boundary (case-insensitive keyword blocklist, not a full parser).400on an unsafenamespaceidentifier or a PostgreSQL syntax/other query error.408if the query exceedstimeout_seconds.503if PostgreSQL isn't connected. - Example.
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"sql": "SELECT document_id, status FROM doc_patient", "namespace": "clinicA", "max_rows": 100}' \ https://.../api/reporting-sync/query{"columns": ["document_id", "status"], "rows": [{"document_id": "doc-123", "status": "active"}], "row_count": 1, "truncated": false}
CSV export
GET /api/reporting-sync/export/csv
- Purpose. Stream a full reporting table as CSV.
- Request. Query:
namespace(required),table(required — bare table name, e.g.doc_patient),timeout_seconds(1–600, default 120),filename(optional download name; defaults to<namespace>_<table>.csv). - Response.
200text/csv, streamed withContent-Disposition: attachment. Header row first, then data rows fetched via a server-side cursor in batches to bound memory. - Errors.
503if PostgreSQL isn't connected.400iftableisn't adoc_*table or one ofterminologies/terms/term_relations/templates(same whitelist asGET /tables), or ifnamespace/tablefails identifier validation. - Example.
curl -H "X-API-Key: $KEY" "https://.../api/reporting-sync/export/csv?namespace=clinicA&table=doc_patient" -o patients.csv
POST /api/reporting-sync/export/csv
- Purpose. Stream the result of an arbitrary read-only SQL query as CSV.
- Request. Body
CsvExportQuery:sql(required),params(default[]),timeout_seconds(1–600, default 120),filename(must match^[\w\-. ]+\.csv$, default"export.csv"). - Response.
200text/csv, streamed withContent-Dispositionset fromfilename. - Errors.
400on the same dangerous-SQL keyword blocklist asPOST /query(nomax_rowscap here — the whole query result streams).503if PostgreSQL isn't connected. - Example.
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"sql": "SELECT document_id FROM doc_patient WHERE status = $1", "params": ["active"]}' \ https://.../api/reporting-sync/export/csv -o active_patients.csv
Namespace deletion
DELETE /api/reporting-sync/namespace/{prefix}
- Purpose. Remove all reporting data for a namespace — called by Registry as part of namespace deletion.
- Request. Path:
prefix(the namespace). - Response.
200{"namespace", "dropped_schema", "total_deleted"}. Because the namespace maps to its own schema, deletion is a singleDROP SCHEMA "<prefix>" CASCADE— everydoc_*and metadata table inside it goes atomically — plus the namespace's rows in the two shared bookkeeping tables (_wip_schema_migrations,_wip_sync_status).total_deletedis always a real integer (rows are counted before the schema is dropped), nevernull. - Errors.
503if PostgreSQL isn't connected.400ifprefixis empty/whitespace or fails identifier validation. - Example.
curl -X DELETE -H "X-API-Key: $KEY" https://.../api/reporting-sync/namespace/clinicA
Root
GET /api/reporting-sync/
- Purpose. Service identity and a map of key endpoints.
- Request. No parameters.
- Response.
200{"service", "version", "build": {sha, built_at, image_tag, ...}, "docs": "/docs", "health": "/api/reporting-sync/health", "status": "/api/reporting-sync/status", "integrity": "/api/reporting-sync/health/integrity", "search": "/api/reporting-sync/search", "activity": "/api/reporting-sync/activity/recent"}. - Errors. None distinctive.
- Example.
curl -H "X-API-Key: $KEY" https://.../api/reporting-sync/
Store-specific gotchas
- Namespace = PostgreSQL schema, not a column. Every table-scoped endpoint (
/table-name,/schema/{template_value},/sync/batch/*,/export/csv,POST /querywithnamespaceset) resolvesnamespaceto a schema name and interpolates it into identifiers after validating it — a value containing"or a NUL byte, or over 63 bytes, is rejected with400rather than silently truncated or escaped (schema_manager.py: SchemaManager._safe_ident). - Append-only templates skip the identity uniqueness guard. A template with an empty (or absent)
identity_fieldslist getsidentity_hash=""on every synced row; the schema manager deliberately omits the partial-unique index on(namespace, identity_hash)for such tables, so every document lands as its own active row instead of colliding on the first insert. This is WIP's append-only identity mode — see PoNIFs (schema_manager.py: generate_create_table_ddl). /table-nameignores a template's cosmetic table-name override. It always computes and returns the defaultdoc_<value>form, even if the template'sreporting.table_nameis set to something else (main.py: resolve_table_namedocstring)./queryandPOST /export/csvare read-only by a keyword blocklist, not a SQL parser.INSERT/UPDATE/DELETE/DROP/ALTER/CREATE/TRUNCATE/GRANT/REVOKEare rejected at word boundaries (case-insensitive regex); the session also setsdefault_transaction_read_only = onas a second layer.POST /queryadditionally wraps every query inSELECT * FROM (...) LIMIT <max_rows+1>—GET /export/csv(table export) andPOST /export/csv(query export) do not cap rows the same way, since the point is to stream the full result.- Full-text search is opt-in per field. Only
string-typed template fields withfull_text_indexed=trueget the<field>_search/<field>_tsv(GENERATED, GIN-indexed) column pair;mode=ftssilently skips anydoc_*table that has no_tsvcolumn at all rather than erroring, andmode=auto(the default) falls back to a substringILIKEscan on that table instead (search_service.py: _search_documents,schema_manager.py: _full_text_columns). - Some
limitquery params validate, others silently clamp.GET /entity/{type}/{id}/referenced-byuses FastAPI'sQuery(ge=1, le=500)and returns422outside that range.GET /activity/recent(1–200) andGET /references/term/{term_id}/documents(1–1000) instead silently substitute their default rather than rejecting the request — the same "limit" concept behaves differently depending on which endpoint you call. - Batch-sync jobs live in memory only.
BatchSyncService._jobsis a plain dict — job history, running/pending status, and progress are all lost on a service restart; there is no persisted job table (batch_sync.py). - Initial metadata sync at startup only backfills metadata, not documents, and only fires after Def-Store's
/healthresponds. If Def-Store never becomes healthy within the retry window, the sync is skipped and the service logs the exactPOST /sync/batch/terminologies|terms|term_relations|templatescalls to run manually once it's reachable (main.py: _initial_metadata_sync).
See also
- API Conventions, PoNIFs, Auth & Access, Routing & Ingress — cross-cutting platform behavior this API's callers should already know.
- Data Model Reference, Identity & the Registry — the field types,
identity_fields, and identity-hash semantics thatschema_manager.pymirrors into PostgreSQL DDL. - Document-Store API, Template-Store API, Def-Store API, Registry API — the sibling stores this service reads from (via their REST APIs for batch sync and reference lookups) and whose events it consumes over NATS.
