A CKAN-compatible datastore API. Tabular data CRUD + search over a pluggable storage backend (BigQuery Datastore or Ducklake as future support).
- CKAN-compatible request/response shapes for
/api/3/action/datastore_*. - Pluggable storage backend selected by
DATASTORE_ENGINE(bigquerytoday;ducklakeplanned). - Pluggable auth selected by
AUTH_TYPE(ckan/jwt/anonymous). Provider lives indatastore/auth/<name>/; only the CKAN provider touches the network, and its TTL cache is local to that provider. - Standalone-capable — runs without an upstream CKAN under
AUTH_TYPE=anonymousorAUTH_TYPE=jwt. CKAN is only required whenAUTH_TYPE=ckan. - Streaming search responses (peak memory ≈ 1 row) for
datastore_search/datastore_search_sql; the sharded-parquet download streams a zip at ≈ 1 chunk. - Strict request validation, structured CKAN-shaped error envelopes.
| Concern | Choice | Why |
|---|---|---|
| Web framework | FastAPI (fastapi[standard]) |
Async, OpenAPI for free, dependency injection |
| ASGI server | uvicorn + uvloop + httptools |
Fast async I/O |
| Validation | Pydantic v2 (request only) | Strict shape validation, no per-row cost |
| JSON | orjson | 5–10× stdlib json, returns bytes, datetime-aware |
| Datastore backend | google-cloud-bigquery | Managed, cached, scalable |
| HTTP client | httpx (AsyncClient) |
Connection-pooled CKAN calls |
| Cache | redis + hiredis |
TTL cache for CKAN auth decisions |
| Schema validation | frictionless | Field schema validation on datastore_create |
| SQL parsing | sqlglot | Parse datastore_search_sql — pull table + function names for the auth + allow-list gates |
| JWT auth | PyJWT | HS*/RS*/ES* signature + aud/iss/exp validation for the JWT provider |
pyproject.toml dependencies (live):
[project]
dependencies = [
"fastapi[standard]>=0.115,<0.116",
"pydantic>=2.7,<3",
"pydantic-settings>=2.3",
"orjson>=3.10",
"google-cloud-bigquery>=3.25",
"redis[hiredis]>=5.0",
"httpx>=0.27",
"frictionless>=5.18",
"uvloop>=0.21",
"httptools>=0.6",
"sqlglot>=25.0",
"pyjwt>=2.8,<3",
]
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.mypy]
strict = trueStack split. Two libraries do most of the heavy lifting and each has one home in the tree:
- Starlette — the web part. Lives in
datastore/api/anddatastore/main.py. Everything that touchesRequest,Response,StreamingResponse, middleware, status codes, routing, orDependslives here. Nothing else imports fromfastapiorstarlette. - Pydantic — the data part. Lives in
datastore/schemas/(request/response models) anddatastore/core/config.py(BaseSettings). Used for boundary validation only — never as the internal data type passed between services or returned from engines (those use plain dicts, dataclasses, and tuples to keep per-row cost at zero).
api ──▶ services ──▶ infrastructure
│ │ ▲
├──▶ auth ─────────────────┤
│ │
└──▶ schemas ◀──────────────┘ (schemas = Pydantic models, plain data)
One-way dependencies. infrastructure/ never imports from api/,
services/, or auth/. services/ and auth/ never import from
api/. api/ is the only layer that knows about FastAPI/Starlette.
auth/ may use infrastructure/ adapters (the CKAN provider needs
CKANClient, all providers may use CachePort).
datastore-api/
│
├── pyproject.toml # Project metadata + deps + tool config
├── README.md
├── CLAUDE.md # This document — design + execution plan
├── .env.example # Template for env vars (every Config field)
├── .gitignore
├── Makefile # run, test, lint, format
├── docker-compose.yml # local: app + redis + ckan
├── Dockerfile
│
├── datastore/
│ ├── __init__.py
│ ├── main.py # FastAPI app factory: create_app() +
│ │ # lifespan (httpx client, cache, ckan client);
│ │ # registers middleware + exception handlers;
│ │ # module-level `app = create_app()` for uvicorn.
│ │
│ │ ── 1. API LAYER ───────────────────────── (FastAPI + Starlette live here)
│ ├── api/
│ │ ├── __init__.py
│ │ ├── routes.py # Top-level APIRouter; mounts endpoints/
│ │ ├── context.py # RequestContext + ContextDep — per-request
│ │ │ # handles (config, api_key, auth_provider, ckan)
│ │ │ # with an `.authorize()` method that delegates
│ │ │ # to auth.py
│ │ ├── auth.py # Provider-agnostic boundary policy: permission
│ │ │ # whitelist, resource_id XOR package_id rule,
│ │ │ # anonymous-read rule. Delegates to the active
│ │ │ # AuthProvider; no caching here (CKAN caches
│ │ │ # internally — see datastore/auth/ckan/).
│ │ ├── responses.py # CKAN envelope helpers (_success_response / _error_response)
│ │ │ # + orjson-backed ORJSONResponse
│ │ ├── error_handlers.py # APIError / HTTPException / RequestValidationError
│ │ │ # → CKAN error envelope mapping
│ │ ├── middleware.py # ASGI middleware (BodySizeLimitMiddleware today)
│ │ └── endpoints/ # One module per resource group
│ │ ├── __init__.py
│ │ ├── health.py # /, /health, /ready (CKAN-shaped envelopes)
│ │ └── datastore.py # /api/3/action/datastore_*
│ │
│ │ ── 2. AUTH PROVIDERS ─────────────────────── (one subpackage per AUTH_TYPE)
│ ├── auth/
│ │ ├── __init__.py
│ │ ├── base.py # AuthProvider Protocol + Decision dataclass +
│ │ │ # default_key_id (JWT jti / sha256 cache-key helper)
│ │ ├── registry.py # get_auth_provider(config, **extras) —
│ │ │ # importlib dispatch by AUTH_TYPE
│ │ ├── ckan/ # AUTH_TYPE=ckan
│ │ │ ├── __init__.py # exports `Provider = CKANAuthProvider`
│ │ │ └── provider.py # datastore_authorize via CKANClient + TTL cache
│ │ ├── jwt/ # AUTH_TYPE=jwt
│ │ │ ├── __init__.py # exports `Provider = JWTAuthProvider`
│ │ │ └── provider.py # PyJWT verify (HS*/RS*/ES* + aud/iss/exp)
│ │ └── anonymous/ # AUTH_TYPE=anonymous
│ │ ├── __init__.py # exports `Provider = AnonymousAuthProvider`
│ │ └── provider.py # always allows; no identity
│ │
│ │ ── 3. CORE (cross-cutting, framework-agnostic) ──────
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py # Pydantic-Settings `Config` (env-driven) +
│ │ │ # `get_config()` lru-cached factory
│ │ ├── constants.py # Shared constants (POSTGRES_TYPES map)
│ │ ├── exceptions.py # APIError taxonomy: ValidationError,
│ │ │ # AuthorizationError, NotFoundError,
│ │ │ # ConflictError, ServerError +
│ │ │ # HTTP_STATUS_TO_TYPE_LABEL map
│ │ └── helper.py # Pure helpers (parse_authorization_header, …)
│ │
│ │ ── 4. SCHEMAS (Pydantic — boundary validation only) ──
│ ├── schemas/ # Inbound request bodies + outbound response
│ │ ├── __init__.py # types. Never passed between services or
│ │ ├── request.py # returned from engines.
│ │ │ # request.py – DatastoreCreateRequest,
│ │ │ # DatastoreUpsertRequest,
│ │ │ # DatastoreSearchRequest
│ │ ├── responses.py # responses.py – ResponseModel base +
│ │ │ # per-endpoint envelopes
│ │ │ # (WelcomeResponse,
│ │ │ # StatusResponse,
│ │ │ # DatastoreCreateResponse)
│ │ └── validators.py # validators.py – FieldSpec, StringOrList,
│ │ # PostgresType, helper fns
│ │
│ │ ── 5. SERVICES (business logic, plain Python) ──────
│ ├── services/ # Orchestration: validate → call engine →
│ │ ├── __init__.py # shape result. Inputs: plain types or
│ │ ├── write.py # validated schemas. Outputs: typed response
│ │ │ # models. No FastAPI, no raw SQL.
│ │ │ # write.py – create / upsert / delete
│ │ ├── read.py # read.py – search / search_sql / info
│ │ │ # (engine call, format
│ │ │ # dispatch, pagination links,
│ │ │ # function allow-list)
│ │ └── streaming.py # streaming.py – byte-yielding writers
│ │ # (objects/lists/csv/tsv
│ │ # + zip_archive_writer)
│ │
│ │ ── 6. INFRASTRUCTURE (adapters to the outside world) ─
│ └── infrastructure/
│ ├── __init__.py
│ ├── cache.py # CachePort (Protocol) + InMemoryCache +
│ │ # RedisCache (TTL-based)
│ ├── ckan_client.py # CKANClient — httpx async wrapper around
│ │ # CKAN /api/3/action; bind(api_key) per request
│ └── engines/ # One subpackage per backend.
│ ├── __init__.py # Re-exports get_datastore_engine, Mode
│ ├── base.py # DatastoreBackend ABC +
│ │ # SearchResult / WriteResult dataclasses
│ ├── registry.py # get_datastore_engine + get_allowed_sql_functions;
│ │ # dynamic `importlib` dispatch keyed on
│ │ # context.config.DATASTORE_ENGINE
│ ├── bigquery/ # Engine package (one folder per backend).
│ | ├── __init__.py # Exports `Backend = BigQueryBackend` —
│ | | # registry imports `Backend`, so the
│ | | # concrete class name is engine-private.
│ | ├── backend.py # DatastoreBackend subclass (placeholder)
│ | ├── export.py # Download pipeline (dump/dump_sql):
│ | | # cache → EXPORT DATA → compose → sign
│ | ├── client.py # google-cloud-bigquery `Client` construction
│ | ├── lib.py # Backend-specific helpers (optional)
│ | └── allowed_functions.txt # Per-engine datastore_search_sql
│ | # function allow-list — one name per
│ | # line, `#` comments allowed.
│ └── ducklake/ # Future planned engine
└── tests/
├── __init__.py
├── conftest.py # FakeCKAN, InMemoryCache, TestClient fixture;
│ # autouse _isolate_bigquery_env clears BQ envs;
│ # CKAN pytest plugin disabled via pyproject
├── test_health.py
├── test_datastore_*.py # End-to-end per endpoint (TestClient)
├── test_read_service.py # Direct service calls — no HTTP
├── test_write_service.py
├── auth/ # One folder per auth provider, mirrors datastore/auth/
│ ├── test_base.py # Decision + default_key_id
│ ├── test_registry.py # AUTH_TYPE dispatch
│ ├── test_orchestration.py # api/auth.py boundary policy
│ ├── ckan/test_provider.py # CKAN provider + TTL cache
│ ├── jwt/test_provider.py
│ └── anonymous/test_provider.py
└── engines/
├── bigquery/test_*.py # Real BigQuery backend, fully mocked
└── ducklake/ # (placeholder for future engine)
Adding a new engine — drop a sibling folder with the same layout
(__init__.py exports Backend = <YourBackend>; backend.py is the
DatastoreBackend subclass; client.py / lib.py for backend-specific
construction + helpers, both optional; allowed_functions.txt lists
allowed SQL functions). No edit to registry.py or config.py is
required — DATASTORE_ENGINE validates against the set of engine
subdirectories that exist at startup, and the factory dispatches via
importlib.import_module keyed off the Backend alias. The ducklake
adapter will live at infrastructure/engines/ducklake/ when it lands.
scripts/ and docs/ are intentionally absent today. Add them when there's a concrete need
(seed scripts, operational runbooks). Until then the README + this file are the docs.
| Folder | Put here | Do NOT put here |
|---|---|---|
datastore/main.py |
App factory, lifespan (httpx, cache, auth provider, engines), middleware order, handler registration | Routes, business logic |
datastore/api/endpoints/ |
Route declarations, request parsing, response building | SQL, engine calls, validation rules — delegate to services |
datastore/api/context.py |
RequestContext, ContextDep, get_context, get_auth_provider, get_ckan_client (per-request DI bundle) |
The logic those handles invoke — that lives in services/ / auth/ / infrastructure/ |
datastore/api/auth.py |
Provider-agnostic boundary policy (permission whitelist, anonymous-read rule, resource_id XOR package_id) | Concrete provider behaviour — CKAN/JWT/anonymous logic lives in datastore/auth/<name>/ |
datastore/api/responses.py |
CKAN envelope helpers, ORJSONResponse |
Anything that needs DB access |
datastore/api/error_handlers.py |
Exception → CKAN error envelope mapping | Business rules — raise APIError from wherever the rule lives |
datastore/auth/<name>/ |
Concrete AuthProvider implementation: __init__.py exports Provider = <ConcreteClass>; provider.py implements authorize + key_id. CKAN provider holds its own TTL cache. |
Cross-provider policy (that's api/auth.py); FastAPI imports |
datastore/auth/base.py |
AuthProvider Protocol, Decision dataclass, default_key_id helper |
Provider implementations |
datastore/auth/registry.py |
importlib factory keyed on AUTH_TYPE |
Instance caching — the lifespan builds once and stashes on app.state |
datastore/core/ |
Config (Config), exceptions, constants, pure helpers |
I/O, FastAPI imports, business orchestration |
datastore/schemas/ |
Pydantic BaseModel request / response / validator types |
Methods that do work — schemas are data shapes only |
datastore/services/ |
Validation that needs cross-input context, calls to engines/cache/CKAN, result shaping | fastapi/starlette imports, raw SQL strings, HTTP clients (call adapters) |
datastore/infrastructure/ |
Adapters: cache (Redis / in-memory), CKAN HTTP client, storage engines (BigQuery / DuckLake) | Business rules, FastAPI types, orchestration, auth providers (those are at datastore/auth/) |
tests/ |
Test code only — tests/auth/<name>/ mirrors datastore/auth/<name>/; tests/engines/<name>/ mirrors datastore/infrastructure/engines/<name>/ |
Fixtures that reach into production internals through back doors — go through the public API |
- Only
datastore/api/anddatastore/main.pymay import fromfastapiorstarlette. Greppable invariant:rg "from (fastapi|starlette)" datastore/services datastore/infrastructure datastore/core datastore/authmust return nothing. - Only
datastore/schemas/anddatastore/core/config.pymay import frompydantic/pydantic_settings. Engines, services, and auth providers pass plain dicts, tuples, and dataclasses. - Engines return a lazy row iterator of tuples, never
list[dict]. Streaming peak memory ≈ 1 row regardless of result size. - Pydantic validates at the boundary; orjson serialises out. Don't use
model.model_dump()on hot paths — build dicts inline andorjson.dumps(). - Auth providers and storage engines are plugins, not registries to edit. Drop a folder under
datastore/auth/<name>/ordatastore/infrastructure/engines/<name>/with__init__.pyexportingProvider/Backend;AUTH_TYPE/DATASTORE_ENGINEare auto-validated against directories on disk. Noregistry.pyorconfig.pyedit required to add either. - Auth caching is provider-private. The only "auth cache" in the codebase is the TTL cache inside
datastore/auth/ckan/provider.py(network round trip; worth caching). JWT and anonymous are local and never cache. - No
container.py/ DI framework. FastAPI'sDependsplus the tworegistry.pyfactories (auth + engines) are the only wiring mechanisms.
flowchart TB
Client([Client])
subgraph K8S["Kubernetes cluster"]
direction TB
Ingress["Ingress<br/>TLS, host routing"]
Service["Service<br/>ClusterIP"]
HPA["HorizontalPodAutoscaler<br/>CPU + req rate"]
subgraph Deploy["Deployment (N replicas)"]
direction LR
Pod1["Pod<br/>FastAPI + uvicorn"]
Pod2["Pod<br/>FastAPI + uvicorn"]
PodN["Pod<br/>..."]
end
Config["ConfigMap<br/>DATASTORE_ENGINE<br/>BQ_PROJECT<br/>MAX_REQUEST_BODY_MB<br/>AUTH_CACHE_TTL"]
Secret["Secret<br/>CKAN API key<br/>BQ_CREDENTIALS_JSON<br/>REDIS_URL"]
Redis[("Redis<br/>StatefulSet or managed<br/>auth + query cache")]
end
CKAN["CKAN<br/>/api/3/action/datastore_authorize<br/>(only when AUTH_TYPE=ckan)"]
BQ["BigQuery API<br/>datastore backend"]
Client -->|HTTPS| Ingress
Ingress --> Service
Service --> Pod1
Service --> Pod2
Service --> PodN
HPA -.scales.-> Deploy
Pod1 -.reads.-> Config
Pod1 -.reads.-> Secret
Pod1 -->|auth cache (CKAN provider only)| Redis
Pod1 -.->|on cache miss| CKAN
Pod1 -->|queries| BQ
classDef ext fill:#fff5e6,stroke:#d97706,color:#7c2d12
classDef k8s fill:#eef6ff,stroke:#2563eb,color:#1e3a8a
classDef store fill:#ecfdf5,stroke:#059669,color:#064e3b
class CKAN,BQ ext
class Ingress,Service,HPA,Pod1,Pod2,PodN,Config,Secret k8s
class Redis store
Inside each pod:
flowchart LR
HTTP([HTTP request]) --> Uvicorn["uvicorn"]
Uvicorn --> MW["api/middleware.py\nbody-size + GZip"]
MW --> Ctx["api/context.py\nget_context → RequestContext"]
Ctx --> Routes["api/endpoints/\ndatastore.py + health.py"]
Routes --> Auth["api/auth.py\nboundary policy"]
Auth --> Provider["auth/<AUTH_TYPE>/provider.py\n(ckan / jwt / anonymous)"]
Provider -->|CKAN provider only| Cache[("infrastructure/cache.py\nInMemory or Redis")]
Provider -.->|CKAN provider, on miss| CKANSvc["CKAN\n/api/3/action/datastore_authorize"]
Routes --> Svc["services/\nwrite.py + read.py + streaming.py"]
Svc --> Eng["infrastructure/engines/\nregistry.get_datastore_engine"]
Eng --> BQ["bigquery/backend.py"]
Eng --> DL[("ducklake/ (planned)")]
Routes --> Resp["api/responses.py\n_success_response / _error_response"]
Resp --> Schema["schemas/responses.py\nResponseModel + Result"]
classDef ext fill:#fff5e6,stroke:#d97706,color:#7c2d12
classDef store fill:#ecfdf5,stroke:#059669,color:#064e3b
class CKANSvc,BQ ext
class Cache,DL store
Layer responsibilities
| Layer | Lives in | Knows about |
|---|---|---|
| HTTP | api/endpoints/, api/routes.py, api/middleware.py |
Request parsing, status codes, FastAPI |
| Request bundle | api/context.py |
Per-request handles: config, api_key, auth_provider, ckan (Optional). .authorize() method delegates to api/auth.py |
| Auth boundary policy | api/auth.py |
Permission whitelist, anonymous-read rule, validation — provider-agnostic |
| Auth providers | auth/<name>/ |
One per AUTH_TYPE. CKAN (network + TTL cache), JWT (PyJWT verify), anonymous (no-op) |
| Response | api/responses.py, schemas/responses.py |
CKAN envelope shape, orjson, typed result models |
| Errors | api/error_handlers.py, core/exceptions.py |
APIError taxonomy → status code + __type label |
| Business logic | services/ |
Orchestration — no FastAPI, no raw SQL, no HTTP plumbing |
| Storage | infrastructure/engines/ |
Backend ABC + concrete adapters; SQL dialect, connection management, row iterators |
| External adapters | infrastructure/cache.py, infrastructure/ckan_client.py |
TTL cache (InMemory / Redis), httpx-based CKAN client |
| Cross-cutting | core/ |
Config, constants, exceptions, pure helpers |
Key design rules
- Endpoints call
context.authorize(...)then services; services call engines. Endpoints never touch SQL. services/write.pydatastore_createis the only path that usescontext.ckan(forresource_createon the dict-resource branch); the endpoint gates that branch onAUTH_TYPE=ckan. All other endpoints work standalone.- Engines return
SearchResultwith a lazy row iterator of tuples — neverlist[dict]. Peak memory ≈ 1 row regardless of result size. - Pydantic validates inbound (
schemas/request.py) and documents outbound (schemas/responses.py). Outbound serialisation goes through_success_response→ORJSONResponse→ orjson. - The CKAN client is built once in the lifespan only when
AUTH_TYPE=ckan;get_contextbinds the caller'sapi_keyper request (a shallow.bind(api_key)copy). Under non-CKAN authapp.state.ckanisNoneand the per-request bound client isNone. - The auth provider is built once in the lifespan (with cache + cache_ttl + ckan client passed as kwargs); registry returns a fresh instance on every call so the lifespan owns instance reuse.
- No DI container. FastAPI's
Depends+ the tworegistry.pyfactories (auth + engines) are the only wiring mechanisms.
Pod-level shape
- One container per pod: the FastAPI app. Sidecars only for observability (e.g., OpenTelemetry collector).
livenessProbe→GET /health(always 200 while the process is up).readinessProbe→GET /ready(200 only when both backends passhealthcheck(); pod pulled from Service when 503).terminationGracePeriodSeconds: 30so in-flight streaming responses drain before SIGKILL.- Memory bounded by
MAX_REQUEST_BODY_MB× concurrency for writes; search responses are O(1) peak memory.
Cluster-level shape
Deploymentwith N replicas, fronted by aClusterIPService.Ingress(NGINX, Traefik, etc.) terminates TLS and routes by host/path.HorizontalPodAutoscaleron CPU + custom metric (request rate).- Config: non-secret env vars in
ConfigMap(DATASTORE_ENGINE,MAX_REQUEST_BODY_MB,BQ_PROJECT,AUTH_CACHE_TTL,HTTP_TIMEOUT_SECONDS); secrets inSecret(CKAN API key,BQ_CREDENTIALS_JSON,REDIS_URL). - Redis as in-cluster
StatefulSetor external managed instance — connection string from Secret. EmptyREDIS_URLfalls back to the in-processInMemoryCache(single-pod only). - DuckLake backend will require single-replica
StatefulSet+PersistentVolumeClaim(when implemented); BigQuery backend supports horizontalDeployment.
All datastore endpoints sit under /api/3/action/ to match the CKAN action API.
Health endpoints at the root.
All three return the CKAN envelope shape {help, success, result: {...}}.
| Method | Path | Status | Result |
|---|---|---|---|
| GET | / |
implemented | {"message": APP_MESSAGE} |
| GET | /health |
implemented | {"status": "ok"} — liveness; always 200 if process is up |
| GET | /ready |
implemented | {"status": "ready"} — calls engine.healthcheck() for rw + ro; 503 with a Service Unavailable envelope if either fails |
Each endpoint takes a single ContextDep. The handler calls context.authorize(...) (which runs the boundary policy + delegates to the active AuthProvider) and then delegates to a service in services/.
| Method | Path | Status | Body / Params | Response model |
|---|---|---|---|---|
| POST | /api/3/action/datastore_create |
implemented | DatastoreCreateRequest |
DatastoreCreateResponse |
| POST | /api/3/action/datastore_upsert |
implemented | DatastoreUpsertRequest |
DatastoreUpsertResponse |
| POST | /api/3/action/datastore_delete |
implemented | DatastoreDeleteRequest |
DatastoreDeleteResponse |
| GET | /api/3/action/datastore_search |
implemented (streaming) | DatastoreSearchRequest |
DatastoreSearchResponse |
| GET | /api/3/action/datastore_search_sql |
implemented (streaming) | DatastoreSearchSQLRequest |
DatastoreSearchResponse |
| GET | /datastore/dump/query |
implemented | sql=<SELECT…>, format=csv|gzip|ndjson|parquet |
302 → GCS or streaming body (see §5.3) |
| GET | /api/3/action/datastore_info |
implemented | DatastoreInfoRequest |
DatastoreInfoResponse |
| GET | /datastore/dump/{resource_id} |
implemented | format=csv|ndjson|parquet |
302 → GCS or streaming body (see §5.3) |
The BigQuery engine is wired end-to-end: DDL, MERGE-based upsert, DML delete, parameterised search, native table-level metadata (the Frictionless schema + unique_key are JSON-encoded into the table's own description OPTION) for the schema round-trip, a row-count fast path via INFORMATION_SCHEMA.TABLE_STORAGE, and EXPORT DATA-backed dump with table.modified-keyed GCS caching. The DuckLake engine is the next concrete adapter — see §7.
datastore_create accepts two shapes:
resource_id— table name only. Works under anyAUTH_TYPE.resource(dict) — callsckan.resource_create(...)first to materialise a CKAN resource, then writes the datastore table. The resource is created withurl_type="datastore"so CKAN (and the read-only guard below) knows the datastore owns its data. Only valid underAUTH_TYPE=ckan; the endpoint rejects this shape with aValidation Errorunder JWT / anonymous since there's no CKAN to land it.
Read-only guard (AUTH_TYPE=ckan only). datastore_create, datastore_upsert, and datastore_delete refuse to write a resource whose CKAN record carries url_type="datastore" unless the request sets force: true — a Validation Error ("Cannot update a read-only resource. Use "force" to force update.") otherwise. This mirrors CKAN's protection against clobbering datastore-managed data by accident. The guard is gated on AUTH_TYPE=ckan and skipped entirely under any other provider (only the CKAN provider attaches a resource record).
Full-table download, one URL → one file from the caller's point of view. Bytes never pass through API memory — the one exception is a sharded parquet export, which is zipped on the way out.
Pipeline (_prepare_download in bigquery/export.py):
- Resolve cache key — read
table.modified,rev = hex(microsec_epoch(modified)), prefixdumps/<rid>/<fmt>/<rev>/. Everything this service writes lives under the singledumps/root — table dumps keyed on the resource id, query dumps on a SQL hash — so one lifecycle rule covers it all. Every request for that (resource, format, table version) shares the revision directory; each individual export writes into its own<attempt-uuid>/beneath it:
dumps/<rid>/<fmt>/<rev>/ ← cache key, shared by all requests
└── <attempt-uuid>/ ← one export's private scratch
├── part_*.<ext>
├── data.<ext> ← composed (csv/gzip, ndjson >1 shard)
└── _SUCCESS ← written last; publishes the attempt
- Cache lookup (
_complete_attempt) — onelist_blobs(prefix=…), grouped by attempt directory; serve the newest attempt carrying_SUCCESS. Attempts without it are in-flight or abandoned: never served, never deleted on the response path. Within the winner a composeddata.<ext>is the whole download;part_*shards beside it are leftovers awaiting background deletion. Parquet never composes, so its shards are the download. - Submit
EXPORT DATA— wildcard URIgs://<bucket>/<prefix><attempt>/part_*.<ext>. The wildcard is mandatory (BigQuery rejects a bare URI — "Option 'uri' value must be a wild card URI") and shards by write parallelism, not size, so a 40 MB result routinely lands as several files.job.result()waits, holding one worker thread for the export's duration. CSV and gzip export header-less (gzip addscompression='GZIP', so BigQuery does the compressing); the SELECT castsTIMESTAMP+DATETIMEto ISO 8601 for CSV/NDJSON andTO_JSON_STRINGfor JSON columns on Parquet. - Compose — csv/gzip/ndjson shards are stitched into ONE object server-side with GCS
compose(≤32 sources per call, chained beyond that). csv and gzip compose a synthesized header member in first — plain bytes for csv, a gzip member for gzip — so the result carries exactly one header. (Verified against the real bucket: BigQuery's gzip objects carry noContent-Encoding, so compose byte-concats them and multi-member gzip decompresses as one file.) Parquet is never composable (footer + magic bytes) and stays as shards. Compose sources are the explicit shard list from this attempt only — never a fresh listing — so nothing foreign can be swept into the output. - Publish — write the zero-byte
<prefix><attempt>/_SUCCESS. This is the commit point: the attempt is unreadable before it, readable after, so no caller ever sees a half-written export. Written after the compose, so it can never become file content. - Cleanup — in the background (
_cleanup_in_background; never on the response path): the compose source shards are deleted, and superseded attempts + revisions underdumps/<rid>/<fmt>/are swept (_delete_old_cache— this request's own attempt is never touched, and the sweep is always age-gated by the signed-URL expiry so a sibling attempt that may still be exporting, or whose URLs are live, survives). - Sign URLs — V4 with
response-content-disposition: attachment; filename="<rid>.<ext>"(one file) or<rid>_NN.<ext>(multi-file parquet, 1-indexed). Signing is offloaded to a thread (IAM round-trip under workload identity). - Return (
download_responsein api/endpoints/dump.py):- 1 URL →
RedirectResponse(302). Bytes flow GCS → client; the server is out of the byte path for every format, and downloads are resumable. - N URLs (sharded parquet only) →
200+ a streamed zip of the parts (zip_archive_writerin services/streaming.py). The API fetches each signed URL overapp.state.httpand frames it into the archive a chunk at a time, so one export is always one file at one URL. Entries areZIP_STORED(parquet is already compressed; deflating costs CPU for no size win) andforce_zip64=True(member sizes aren't known up front). This is the only path where the server carries the bytes — noContent-Length, no range support, and a fetch failure mid-archive truncates a response that already returned 200.
- 1 URL →
Errors:
- Any BigQuery / GCS failure →
ServerError(500) with the upstream message. A ">1 GB single URI" failure is classified by_is_export_too_largeintoPayloadTooLargeError(413) — defensive only, since the wildcard URI means BigQuery shards instead of failing. BIGQUERY_EXPORT_BUCKETunset →ServerErrorat request time (the lifespan doesn't fail-fast because dump is an optional capability).- Concurrency. Requests for the same (query, table version) share a revision directory but export into separate attempt directories, so they can never overwrite each other's objects, and neither is servable until its own
_SUCCESSlands. The residual cost is that both run an export — duplicate billed scans, bounded and rare. Fixing that needs single-flighting (an in-process lock, or a deterministic BigQuery job id so the loser waits on the winner's job); it is a cost optimisation, not a correctness one, and is deliberately not implemented.
Required IAM. Dump follows a strict ro for reading, rw for writing/updating model — see bigquery/client.py load_credentials + _build_bq_client / _build_storage_client on the backend:
| Step | Identity | Why |
|---|---|---|
get_table |
RO BQ (self.client) |
Reading BigQuery metadata. |
list_blobs cache lookup |
RO GCS | Reading GCS objects. |
client.query("EXPORT DATA …") |
RW BQ (built on demand) | BigQuery writes shards to GCS under this SA's identity — it's a write op even though the SQL surface is SELECT. |
Post-extract list_blobs refresh |
RW GCS | Blobs are passed straight to generate_signed_url next; we want them bound to the rw client. |
compose (csv/ndjson) |
RW GCS | Server-side concat into one object: reads each source's metadata (storage.objects.get — list alone is not enough) and creates the composite. |
upload_from_string (csv header member) |
RW GCS | The one-row header composed in front of the header-less csv shards. |
delete (GC) |
RW GCS | Writing/deleting objects. |
generate_signed_url |
RW GCS | Under workload identity this calls IAM signBlob, which typically only the rw SA holds via iam.serviceAccountTokenCreator. |
Concrete perm sets:
- RO SA (
BIGQUERY_CREDENTIALS_RO) —bigquery.tables.get+storage.objects.list. - RW SA (
BIGQUERY_CREDENTIALS) —bigquery.jobs.create+bigquery.tables.export+bigquery.tables.getData+storage.objects.{create,get,list,delete}+iam.serviceAccountTokenCreator.getis required bycompose(it reads each source object); a role with only create/list/delete 403s on the compose step.
A single SA works if both perm sets land on the same identity — BIGQUERY_CREDENTIALS_RO empty falls through to ADC; same env var can drive both. _build_bq_client and _build_storage_client on the backend are deliberately small + stub-friendly so tests inject mocks without monkey-patching google.cloud.* globally.
A 24h object-lifecycle rule on the bucket is required in practice: the engine GCs older revs already, but lifecycle is the only thing that cleans abandoned dumps/<qhash>/ prefixes (SQL downloads whose query is never re-issued — see below) and anything stranded by a crashed dump.
GET /datastore/dump/query?sql=<SELECT…>&format=csv|gzip|ndjson|parquet exports the result of an arbitrary vetted SELECT through the same pipeline as /datastore/dump/{resource_id} — engine method dump_sql in bigquery/export.py, response shaping shared via download_response in api/endpoints/dump.py (302 for the composed file · gzip streamed · JSON URL list for multi-file parquet). Same SQL validation + per-table auth as datastore_search_sql (DatastoreDumpSQLRequest subclasses its request schema); the action API itself stays pure JSON envelope. The route is declared before /datastore/dump/{resource_id}, making query a reserved resource name on the dump family.
Deltas vs the whole-table dump:
- LIMIT is optional, uncapped.
datastore_search_sqlrequires a LIMIT literal; the dump request schema relaxes it (parse_sql_pagination(require_limit=…)via the_REQUIRE_LIMITclass flag), honors a present LIMIT as written, andSEARCH_RESULT_ROWS_MAXdoes not apply. OFFSET without LIMIT is rejected. - Cache key =
dumps/<qhash>/<fmt>/<rev>/:qhash= sha256 of the qualified SQL,rev= sha256 over every referenced table's(rid, modified)pair — any table change → new rev. Query dumps share thedumps/root with table dumps; the identity segment is a 16-hex hash rather than a resource id, so the two only collide if a table is literally named like one. - Non-deterministic SQL bypasses the cache. Queries calling
now(),current_date, … (_NON_DETERMINISTIC_SQL_FUNCTIONS) skip the lookup and export under a fresh uuid rev per run. - RO dry run → RW export. The user SQL is dry-run on the RO client first (free; clean 400 on SQL that doesn't compile; yields the output schema for the same per-format casts
dump()uses — ISO timestamps for CSV/NDJSON,TO_JSON_STRINGfor JSON→parquet). TheEXPORT DATAitself must run under the RW SA (it writes GCS objects); containment = single-statement/SELECT-only schema validation + per-table authorize + function allow-list + the user SQL riding in subquery position (AS SELECT … FROM (<sql>)). - Age-gated GC. Stale revisions under
dumps/<qhash>/<fmt>/are deleted only once older than the signed-URL expiry, so a re-export can't kill shards whose URLs are still live. - Row order (
_outer_order_by): BigQuery ignores a subquery's ORDER BY without LIMIT, so ordering lives on the outer exported query — a user's top-levelORDER BYis hoisted there when its keys are output columns; with no ORDER BY,ORDER BY _idis applied when_idis in the output (mirrors JSON mode'sdefault_order_by). Otherwise the file is unordered. BigQuery preserves outer ORDER BY globally across shards; shards concat in name order. - Every cache miss is a billed query (EXPORT DATA never uses BigQuery's result cache);
maximum_bytes_billedis a possible future cost cap.
The GCS client is built with the same credentials as the BigQuery client for the active engine mode (load_credentials(config, mode) in bigquery/client.py). Without this shim, a service-account JSON loaded via BIGQUERY_CREDENTIALS_RO would drive BigQuery but storage.Client(...) would silently fall back to ADC — a near-invisible identity split. Workload identity / GOOGLE_APPLICATION_CREDENTIALS-style setups still work because load_credentials returns None for ADC and the storage client follows the same default-credentials path.
Every response is the CKAN envelope — help, success, and either result or error. The full per-endpoint reference (request bodies, query params, worked examples, and error shapes) lives in API.md.
CKAN-style envelope: every response has help, success, and either result or error.
Running example: an electricity balancing-market auction-results table. Used consistently across the rest of §6 so the request → search → info round-trip is easy to follow.
Request
{
"resource_id": "balancing_auction_results_2025",
"fields": [
{
"id": "auction_id",
"type": "integer",
"info": {
"title": "Auction ID",
"description": "Unique auction identifier. Stable across all products auctioned in the same market window.",
"comment": "MANDATORY",
"example": "144",
"unit": "N/A"
}
},
{
"id": "product_code",
"type": "string",
"info": {
"title": "Product Code",
"description": "Product mnemonic for the balancing service (e.g. DCL, DCH, FFR).",
"example": "DCL"
}
},
{
"id": "delivery_start",
"type": "datetime",
"info": {
"title": "Delivery Start (UTC)",
"description": "First instant of the delivery window. Stored as UTC; clients render local time.",
"example": "2025-11-04T16:00:00Z"
}
},
{
"id": "duration_minutes",
"type": "integer",
"info": {
"title": "Delivery Duration",
"description": "Length of the delivery window.",
"unit": "minutes",
"example": "30"
}
},
{
"id": "clearing_price_gbp_per_mwh",
"type": "number",
"info": {
"title": "Clearing Price",
"description": "Pay-as-cleared price for the auction. Negative values are possible during oversupply.",
"unit": "GBP/MWh",
"example": "47.82"
}
},
{
"id": "volume_mwh",
"type": "number",
"info": {
"title": "Cleared Volume",
"description": "Total volume cleared in this auction.",
"unit": "MWh",
"example": "120.0"
}
},
{
"id": "accepted",
"type": "boolean",
"info": {
"title": "Accepted",
"description": "Whether the bid cleared (true) or was rejected (false)."
}
},
{
"id": "bidder_metadata",
"type": "object",
"info": {
"title": "Bidder Metadata",
"description": "Free-form provider-specific metadata captured at submission time.",
"comment": "Schema not enforced; kept opaque for downstream analytics."
}
}
],
"unique_key": ["auction_id", "product_code"],
"records": [
{
"auction_id": 144,
"product_code": "DCL",
"delivery_start": "2025-11-04T16:00:00Z",
"duration_minutes": 30,
"clearing_price_gbp_per_mwh": 47.82,
"volume_mwh": 120.0,
"accepted": true,
"bidder_metadata": {"unit_id": "DRAX-1", "submission_lag_ms": 412}
},
{
"auction_id": 144,
"product_code": "DCH",
"delivery_start": "2025-11-04T16:00:00Z",
"duration_minutes": 30,
"clearing_price_gbp_per_mwh": 51.10,
"volume_mwh": 75.5,
"accepted": true,
"bidder_metadata": {"unit_id": "EDF-COTT-2", "submission_lag_ms": 280}
}
]
}resource_id— SQL identifier, required.fields— non-empty; each entry contains:id(or aliasname) — column identifier; SQL-safe.type— column type. Accepts Frictionless canonical (integer,number,string,boolean,date,datetime,time,object,array,geopoint,geojson,any) or SQL aliases (int4,int8,bigint,varchar,text,float,double,numeric,bool,timestamp,json, …) which are normalised to canonical on storage.info— optional data dictionary for documentation. Free-form object; recognised keys:title,description,comment,example,unit, plus any custom metadata. Stored verbatim and round-tripped ondatastore_info. The outertypeis canonical; anyinfo.typeis treated as a hint and ignored. Whitespace in string values is trimmed.
unique_key— string or list of strings; all entries must reference declared field ids. The example uses a composite key (auction_id+product_code) since one auction clears multiple products.records— optional; each record's keys must be a subset of declared field ids.primary_key— accepted for back-compat; emits deprecation warning.
Response — 200
{
"help": "<request URL>",
"success": true,
"result": {
"resource_id": "balancing_auction_results_2025",
"fields": [
{"id": "auction_id", "type": "integer", "info": {"title": "Auction ID", "...": "..."}},
{"id": "product_code", "type": "string", "info": {"...": "..."}},
{"id": "delivery_start", "type": "datetime", "info": {"...": "..."}},
{"id": "duration_minutes", "type": "integer", "info": {"...": "..."}},
{"id": "clearing_price_gbp_per_mwh", "type": "number", "info": {"...": "..."}},
{"id": "volume_mwh", "type": "number", "info": {"...": "..."}},
{"id": "accepted", "type": "boolean", "info": {"...": "..."}},
{"id": "bidder_metadata", "type": "object", "info": {"...": "..."}}
],
"primary_key": ["auction_id", "product_code"],
"unique_key": ["auction_id", "product_code"]
}
}Optional response fields (omitted from the body when not requested):
records— echoes the input rows back when the request setsinclude_records: true.total— total row count after the write, populated wheninclude_total: true.
Query params
| Name | Type | Default | Notes |
|---|---|---|---|
resource_id |
str | — | required unless q supplied |
filters |
JSON-encoded object | null |
{"col": value} or {"col": [v1, v2]} |
q |
str / JSON | null |
full-text or per-column |
distinct |
bool | false |
|
plain |
bool | true |
|
language |
str | "english" |
reserved |
limit |
int | 1000 |
clamped to [0, 10000] |
offset |
int | 0 |
|
fields |
comma-separated list | all | |
sort |
str | null |
"col asc, col2 desc" |
include_total |
bool | true |
runs COUNT(*) if true |
records_format |
str | "objects" |
objects / lists / csv / tsv |
Example request
GET /api/3/datastore_search
?resource_id=balancing_auction_results_2025
&filters={"product_code": "DCL", "accepted": true}
&sort=delivery_start desc, clearing_price_gbp_per_mwh asc
&fields=auction_id,product_code,delivery_start,clearing_price_gbp_per_mwh,volume_mwh
&limit=100
&offset=0
Response (records_format=objects) — streamed
{
"help": "...",
"success": true,
"result": {
"fields": [
{"id": "auction_id", "type": "integer"},
{"id": "product_code", "type": "string"},
{"id": "delivery_start", "type": "datetime"},
{"id": "clearing_price_gbp_per_mwh", "type": "number"},
{"id": "volume_mwh", "type": "number"}
],
"records_format": "objects",
"records": [
{"auction_id": 152, "product_code": "DCL", "delivery_start": "2025-11-05T18:30:00Z", "clearing_price_gbp_per_mwh": 39.40, "volume_mwh": 95.0},
{"auction_id": 144, "product_code": "DCL", "delivery_start": "2025-11-04T16:00:00Z", "clearing_price_gbp_per_mwh": 47.82, "volume_mwh": 120.0}
],
"total": 2,
"_links": {
"start": "https://example.com/api/3/action/datastore_search?resource_id=balancing_auction_results_2025&limit=100",
"next": "https://example.com/api/3/action/datastore_search?resource_id=balancing_auction_results_2025&limit=100&offset=100"
}
}
}_links carries the same scheme + host as the request URL, with all
non-offset params preserved. start omits offset (it defaults to 0);
next advances offset by limit. Clients detect end-of-data by an
empty records array on the next page — there's no prev field today.
records_format=lists returns each record as a positional array (column order matches fields).
records_format=csv / tsv return a streaming text body of data rows (no header row — column names are on fields).
result.records_format echoes back the format that was applied (always objects for
datastore_search_sql), so a client can tell which records shape it got.
Request — late-arriving correction to an auction result
{
"resource_id": "balancing_auction_results_2025",
"method": "upsert",
"unique_key": ["auction_id", "product_code"],
"records": [
{
"auction_id": 144,
"product_code": "DCL",
"delivery_start": "2025-11-04T16:00:00Z",
"duration_minutes": 30,
"clearing_price_gbp_per_mwh": 48.05,
"volume_mwh": 120.0,
"accepted": true,
"bidder_metadata": {"unit_id": "DRAX-1", "submission_lag_ms": 412, "revision": 2}
},
{
"auction_id": 153,
"product_code": "FFR",
"delivery_start": "2025-11-05T19:00:00Z",
"duration_minutes": 60,
"clearing_price_gbp_per_mwh": 32.40,
"volume_mwh": 200.0,
"accepted": false,
"bidder_metadata": {"unit_id": "SSE-PEH-3", "rejection_reason": "above_cap"}
}
],
"include_records": false,
"include_total": false,
"force": false
}method:upsert|insert|update. The table's storedunique_key(set atdatastore_create) decides which rows match — the request body itself never carries it.include_records: iftrue, echoes the written rows back in the response.include_total: iftrue, the engine runs aCOUNT(*)after the write and populatesresult.total. Off by default.force: bypasses optional client-side guards (reserved; backend-specific).
Response
{
"help": "...",
"success": true,
"result": {
"resource_id": "balancing_auction_results_2025",
"method": "upsert"
}
}Optional fields appear in result only when requested:
records— echoes input rows wheninclude_records: true.total— total row count after the write wheninclude_total: true.
null is never serialised — fields that aren't populated are simply omitted (see _orjson_default in api/responses.py).
Query params: sql (required; must carry a LIMIT literal). To export the result as a file instead of the JSON envelope, use GET /datastore/dump/query?sql=…&format=… (LIMIT optional + uncapped there — see §5.3 "SQL download").
Example request — daily clearing-price summary
GET /api/3/datastore_search_sql?sql=
SELECT
DATE(delivery_start) AS delivery_date,
product_code,
AVG(clearing_price_gbp_per_mwh) AS avg_price,
SUM(volume_mwh) AS total_volume
FROM balancing_auction_results_2025
WHERE accepted = true
AND delivery_start >= '2025-11-01'
GROUP BY delivery_date, product_code
ORDER BY delivery_date DESC, product_code
&limit=10000
Response — streamed
{
"help": "...",
"success": true,
"result": {
"fields": [
{"id": "delivery_date", "type": "date"},
{"id": "product_code", "type": "string"},
{"id": "avg_price", "type": "number"},
{"id": "total_volume", "type": "number"}
],
"records_format": "objects",
"records": [
{"delivery_date": "2025-11-05", "product_code": "DCL", "avg_price": 41.20, "total_volume": 1840.0},
{"delivery_date": "2025-11-05", "product_code": "DCH", "avg_price": 49.75, "total_volume": 720.5},
{"delivery_date": "2025-11-04", "product_code": "DCL", "avg_price": 47.82, "total_volume": 1200.0}
],
"records_truncated": false
}
}Request — purge rejected bids for a single auction window
{
"resource_id": "balancing_auction_results_2025",
"filters": {
"auction_id": 144,
"accepted": false
},
"force": false
}Empty filters (or omitted) → the entire table is dropped. Passing fields
(mutually exclusive with filters) drops those columns instead of rows.
Response
{
"help": "...",
"success": true,
"result": {"resource_id": "balancing_auction_results_2025"}
}When fields is supplied (column drop), result also carries schema — the
Frictionless Table Schema after the listed columns were removed — so the caller
can confirm the table's new shape without a follow-up datastore_info:
{
"help": "...",
"success": true,
"result": {
"resource_id": "balancing_auction_results_2025",
"fields": ["bidder_metadata"],
"schema": {"fields": [{"id": "auction_id", "type": "integer"}, "..."], "primaryKey": ["auction_id", "product_code"]}
}
}Returns the same field shape that was supplied to datastore_create, including
the info data dictionary verbatim — clients can use this as a column-level
metadata catalog (titles, descriptions, units, examples) without a side store.
Response
{
"help": "...",
"success": true,
"result": {
"resource_id": "balancing_auction_results_2025",
"fields": [
{
"id": "auction_id",
"type": "integer",
"info": {
"title": "Auction ID",
"description": "Unique auction identifier. Stable across all products auctioned in the same market window.",
"comment": "MANDATORY",
"example": "144",
"unit": "N/A"
}
},
{
"id": "product_code",
"type": "string",
"info": {
"title": "Product Code",
"description": "Product mnemonic for the balancing service (e.g. DCL, DCH, FFR).",
"example": "DCL"
}
},
{
"id": "delivery_start",
"type": "datetime",
"info": {
"title": "Delivery Start (UTC)",
"description": "First instant of the delivery window. Stored as UTC; clients render local time.",
"example": "2025-11-04T16:00:00Z"
}
},
{"id": "duration_minutes", "type": "integer", "info": {"title": "Delivery Duration", "unit": "minutes"}},
{"id": "clearing_price_gbp_per_mwh", "type": "number", "info": {"title": "Clearing Price", "unit": "GBP/MWh"}},
{"id": "volume_mwh", "type": "number", "info": {"title": "Cleared Volume", "unit": "MWh"}},
{"id": "accepted", "type": "boolean", "info": {"title": "Accepted"}},
{"id": "bidder_metadata", "type": "object", "info": {"title": "Bidder Metadata"}}
],
"unique_key": ["auction_id", "product_code"],
"primary_key": ["auction_id", "product_code"],
"total": 18420
}
}{
"help": "<request URL>",
"success": false,
"error": {
"__type": "Validation Error",
"message": "fields[0].id is not a valid identifier: '1bad'",
"fields": {"fields": ["..."]} // optional, present on validation errors
}
}__type taxonomy: Validation Error (400), Authorization Error (403), Not Found Error (404), Conflict Error (409), Internal Error (500).
The original phase plan that used to live here has mostly shipped. This section now tracks what's done, what's next, and the guardrails that apply to every change. For the current file layout see §3.
- Foundation —
pyproject.toml,Dockerfile,Makefile,.env.example,docker-compose.yml. App factory + lifespan in datastore/main.py; body-size middleware in datastore/api/middleware.py; startup log line viauvicorn.errorshowing the active engine + auth provider + cache backend. - All six
datastore_*actions wired —create,upsert,delete,search,search_sql,infomounted via datastore/api/routes.py. Every endpoint authorizes viacontext.authorize(...)and delegates to a service. - Real BigQuery backend — datastore/infrastructure/engines/bigquery/ implements DDL, parameterised
search, MERGE-basedupsert(method=upsert/insert/update), DMLdelete(whole-table drop, row delete, column drop), parameterisedsearch_sql, andinfo. Frictionless schema +unique_keyround-trip via native table-level metadata — JSON-encoded into the table's owndescriptionOPTION (no separate metadata table). Row counts use the cheapINFORMATION_SCHEMA.TABLE_STORAGEfast path when filters don't apply. - Streaming search — datastore/services/streaming.py yields the CKAN envelope chunk-by-chunk for all four
records_formatvalues (objects,lists,csv,tsv); CSV/TSV ride the same JSON envelope (records is a multi-line string). Peak memory ≈ 1 row regardless of N._links.start/_links.nextcarry full scheme + host with all non-offsetparams preserved. -
datastore_search_sqlSQL safety — schema rejects non-SELECT / multi-statement / unparseable SQL (sqlglot). datastore/schemas/validators.py'sparse_sql_referencespulls table + function names; endpoint authorizes each table as aresource_id; service rejects functions outside the engine's allow-list atengines/<name>/allowed_functions.txt(overridable viaSQL_FUNCTIONS_ALLOW_FILE). - Request validation — Pydantic models in datastore/schemas/request.py with
extra="forbid".datastore_info/datastore_deleteacceptresource_idorid(normalised). Pydantic errors → CKAN error envelope with afieldsmap. - Response models — datastore/schemas/responses.py — one envelope per endpoint with a nested
Resultclass. Routes declareresponse_model=...for OpenAPI; services return the typed innerResult. - Error envelope — handlers in datastore/api/error_handlers.py; taxonomy in datastore/core/exceptions.py.
- Pluggable auth providers —
AUTH_TYPEselects a folder under datastore/auth/. Built-in:ckan(delegates todatastore_authorizewith a provider-local TTL cache),jwt(PyJWT verify HS*/RS*/ES* +aud/iss/exp),anonymous(allow-all). Boundary policy in datastore/api/auth.py is provider-agnostic. Adding a new provider = drop a folder; no registry / config edit. - Standalone capability —
CKANClientis only constructed whenAUTH_TYPE=ckan;RequestContext.ckanisCKANClient | None.Configvalidator rejectsAUTH_TYPE=ckan+ emptyCKAN_URLat startup.datastore_createresourcedict path is gated on CKAN auth; everything else runs without an upstream CKAN. -
/readyhealthcheck — lifespan builds rw + ro engine instances and stashes onapp.state;/readycallsengine.healthcheck()on both and returns 503 +Service Unavailableenvelope if either fails. - Request context —
RequestContext+ContextDepin datastore/api/context.py; CKAN client bound to the caller'sapi_keyper request (orNoneunder non-CKAN auth)..authorize()method delegates toapi/auth.pypolicy + active provider. - Engine + auth registries —
DatastoreBackendABC + result dataclasses in engines/base.py;AuthProviderProtocol +Decisionin auth/base.py. Each subpackage exportsBackend/Provider;DATASTORE_ENGINE/AUTH_TYPEare validated against directories on disk at startup; registries dispatch viaimportlib. - Postman collection — postman/collection.json auto-generated from
example_payload/bypostman/generate_postman.py; covers every endpoint with a worked example. - Tests — ~290 tests across endpoint, service, auth provider, and engine layers. CKAN pytest plugin disabled via
addoptsinpyproject.toml.
Rough priority order. Tick each box as the change set lands.
- DuckLake backend. Second concrete engine implementing
DatastoreBackend. Single-replicaStatefulSet+PersistentVolumeClaimin k8s. Local mode readsDUCKDB_PATH; DuckLake mode reads a catalog URL. - Observability. JSON structured logger in
core/logging.py; per-request middleware inapi/middleware.pyinjects arequest_idand logsmethod,path,status,duration_ms. The existinglog.debuglines in auth + error handlers + the CKAN provider light up underLOG_LEVEL=DEBUG. - Per-table SQL auth for
datastore_search_sql— today the endpoint authorizes each table the schema extracts via the active provider, but CKAN'sdatastore_search_sql_authorizeis a separate action that takes the SQL string. Wire it throughcontext.ckanfor the CKAN provider as a tighter check; JWT / anonymous providers stay table-by-table. - Opt-in query-result cache. The CKAN auth provider already caches its own decisions. A separate cache for small / hot SELECTs would ride on the existing
CachePort. Not on the critical path — defer until there's a workload that needs it. -
terminationGracePeriodSeconds: 30in the k8s manifest so streaming responses drain on SIGTERM.
Apply to every change, current and future:
| Invariant | Check |
|---|---|
| App starts | uvicorn datastore.main:app exits 0 |
| Health always works | GET /health → 200 |
| OpenAPI loads | GET /datastore/api/docs renders without error |
| Tests stay green | pytest passes |
| Layer arrow holds | rg "from (fastapi|starlette)" datastore/services datastore/infrastructure datastore/core returns nothing |
Hard rules from §3 (recap):
- Only
datastore/api/anddatastore/main.pymay import fromfastapi/starlette. - Only
datastore/schemas/anddatastore/core/config.pymay import frompydantic/pydantic_settings. - Engines return lazy row iterators of tuples (when streaming lands). Never
list[dict]. - Pydantic validates at the boundary; orjson serialises out via
_success_response. - No DI container — FastAPI's
Depends+ the engineregistry.pyfactory are the only wiring.