Skip to content

feat(client): cloud-http transport resolver — self_hosted clients actually use the cloud (B2/B5)#16

Merged
andrei-hasna merged 2 commits into
mainfrom
feat/client-flip-transport
Jul 7, 2026
Merged

feat(client): cloud-http transport resolver — self_hosted clients actually use the cloud (B2/B5)#16
andrei-hasna merged 2 commits into
mainfrom
feat/client-flip-transport

Conversation

@andrei-hasna

Copy link
Copy Markdown
Contributor

What

The B2 core fix. Setting a client to cloud/self_hosted was a no-op — the CLI/MCP kept reading the local SQLite/db.json store. A DSN on the client does not switch the dataset a CLI reads.

Adds a shared client-flip mechanism in @hasna/contracts (root + ./client subpath) that every app's storage resolver adopts:

  • resolveClientTransport(name, env) — decides local vs cloud-http. Transport is cloud-http IFF resolved mode is cloud AND an API key is present. Base URL defaults to https://<app>.hasna.xyz; /v1 appended. If cloud is requested but the key is missing / URL invalid → returns local + misconfigured: true with a loud warning (never silently serves wrong local data).
  • createHasnaHttpTransport() — authenticated fetch client for <base>/v1; sends the key as both x-api-key and Authorization: Bearer; typed errors; never logs the key.
  • createClientTransport() — resolve + build in one call; throws on misconfig.

The flip contract (env), per app <NAME>

var meaning
HASNA_<NAME>_STORAGE_MODE / HASNA_<NAME>_MODE cloud | self_hosted | local
HASNA_<NAME>_API_URL base (default https://<app>.hasna.xyz)
HASNA_<NAME>_API_KEY hasna_<app>_...

Rollback = unset those; the local original is never touched.

B5

The transport routes ALL reads+writes through the app's HTTP API, so the data source is the server (whatever it persists — db.json items, not just sqlite tables).

Proof

src/client/transport.test.ts stands up a real loopback /v1 server + a demo storage resolver using the shared code and proves: flip on → reads/writes hit the cloud server (write lands in the server store, local file untouched); flip off → local file; key sent + enforced. 13/13 pass; full suite 156 pass / 0 fail; typecheck + build clean.

Follow-on (not in this PR)

  • Per-app storage-resolver adoption (todos CLI is coupled to raw bun:sqlite via getDatabase(); knowledge to db.json + an older /api/v1/* contract) — each needs a small resolver swap to route through this transport.
  • Publish after merge (branch is based on origin/main, which is behind npm 0.4.2; publishing from this base would regress — reconcile main first).

…s actually use the cloud (B2)

The core B2 fix. Setting a client to cloud/self_hosted mode was a no-op: the
CLI/MCP kept reading the local SQLite/db.json store because a DSN on the client
does not switch the dataset a CLI reads.

Add a shared client-flip mechanism in @hasna/contracts (exported at the root and
at the ./client subpath) that every app's storage resolver can adopt:

- resolveClientTransport(name, env): decides local vs cloud-http from the env
  flip contract. Transport is cloud-http IFF the resolved mode is cloud AND an
  API key is present; base URL defaults to https://<app>.hasna.xyz and /v1 is
  appended. If cloud is requested but the API key is missing/URL invalid, it
  returns local + misconfigured=true with a loud warning so callers hard-fail
  instead of silently serving wrong local data.
- createHasnaHttpTransport(): authenticated fetch client for <base>/v1, sends the
  key as both x-api-key and Authorization: Bearer, JSON in/out, typed errors.
- createClientTransport(): resolve + build in one call; throws on misconfig.

Flip contract (env), per app <NAME>:
  HASNA_<NAME>_STORAGE_MODE | HASNA_<NAME>_MODE = cloud|self_hosted|local
  HASNA_<NAME>_API_URL  (default https://<app>.hasna.xyz)
  HASNA_<NAME>_API_KEY  (hasna_<app>_...)
Rollback = unset the mode/url/key; local original is never touched.

The transport routes ALL reads+writes through the app's HTTP API, so it covers
the app's REAL dataset (db.json items, not just sqlite tables) — addressing B5:
the data source is the server, whatever the server persists.

Includes an end-to-end test that stands up a real loopback /v1 server plus a demo
storage resolver using the shared code, proving: flip on => reads/writes hit the
cloud server; flip off => local file; key is sent and enforced. Never logs the key.
…h retries + idempotency

Adds createHasnaStorageClient + resolveStorageClient on top of the cloud-http
transport: the resource CRUD interface every Hasna serve app exposes under /v1,
which an app's storage resolver selects when mode=self_hosted and API_URL+API_KEY
are set (else local). Transport gains per-call query, retries (exp backoff+jitter,
idempotent methods + POST-with-Idempotency-Key), and caller-abort propagation.
Errors surface as HasnaHttpError. Live conformance test round-trips CRUD against a
real cloud app (key from Secrets Manager; skips offline). Exported via
@hasna/contracts and ./client/storage. Version 0.5.0.
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Adds the HTTP storage client on top of the cloud-http transport (commit ccc7f5f):

  • createHasnaStorageClient(name, transport) — the app storage interface over /v1: list (GET, extracts items/total), get (GET /:id, 404→null), create (POST + auto Idempotency-Key), update (PATCH default / PUT), delete (DELETE /:id, 204/404 ok).
  • resolveStorageClient(name, env) — the single call an app's resolver makes: returns a ready client on cloud-http (mode=self_hosted + API_URL + API_KEY), else {transport:'local',client:null}; throws if cloud requested but misconfigured.
  • Transport: per-call query, retries (exp backoff+jitter; idempotent methods + POST-with-key only), caller-abort propagation, HasnaHttpError on non-2xx.
  • Live conformance test round-trips create→get→list→update→delete against knowledge.hasna.xyz/v1 (key from Secrets Manager; skips offline).
  • Exported from root + @hasna/contracts/client/storage. verify:release green; version 0.5.0.

@andrei-hasna
andrei-hasna merged commit ff15096 into main Jul 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant