Metadata-Version: 2.5
Name: invariantdb
Version: 0.9.0
Summary: Official Python client for InvariantDB — a bitemporal, tamper-evident graph database.
Project-URL: Homepage, https://invariantdb.com
Project-URL: Documentation, https://invariantdb.com/docs
Author-email: "InvariantDB Inc." <hello@invariantdb.com>
License: LicenseRef-Proprietary
Keywords: audit,bitemporal,compliance,cypher,graph-database
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Database
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Requires-Dist: typing-extensions>=4.9; python_version < '3.11'
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# invariantdb — Python client

Official Python client for [InvariantDB](https://invariantdb.com), a
bitemporal, tamper-evident graph database.

## Install

Until the package is published to PyPI, install directly from the AWS-hosted
CloudFront distribution:

```bash
# Direct wheel URL (simplest):
pip install https://sdk.invariantdb.com/python/invariantdb-0.9.0-py3-none-any.whl

# Or via the PEP 503 simple index (gets future versions automatically):
pip install --extra-index-url https://sdk.invariantdb.com/simple/ invariantdb
```

## Quickstart

```python
from invariantdb import Client

c = Client(url="https://invariantdb.com", api_key="gq_...")

# Cypher
result = c.cypher("my_graph", "MATCH (n:Person) RETURN n.name LIMIT 10")
for row in result.as_dicts():
    print(row["n.name"])

# Atomic writes
c.write("my_graph", [
    {"createNode": {"labels": ["Person"], "properties": {"name": "Ada"}}},
])

# Graphs
c.graphs.list()
c.graphs.create("new_graph")
c.graphs.clone(source="prod", destination="prod_snapshot")

# Schema (versioned)
c.schema.put("my_graph", {"labels": ["Person"], "constraints": []})
versions = c.schema.history("my_graph")

# Search
hits = c.search.fulltext("my_graph", label="Document", property="body", query="invoice")
hits = c.search.vector("my_graph", label="Document", property="embedding", vector=[...], k=10)

# Audit chain
status = c.audit.verify("my_graph")
assert status.is_clean

# Compliance bundle
c.compliance.export("my_graph", output="/var/lib/exports/bundle.tar.gz")

# Async imports
job = c.jobs.wait(
    c._t.request("POST", "/graphs/my_graph/import", json={"operations": [...]})["jobId"]
)
assert job.is_succeeded

# Context manager closes the connection pool
with Client("https://invariantdb.com", api_key="gq_...") as c:
    c.cypher("my_graph", "MATCH (n) RETURN count(n)")
```

## Procedures — machine-readable catalog

The engine ships ~70 built-in procedures across semantic search,
agent-memory, provenance, audit, embedding lifecycle, and schema
management. Enumerate them programmatically:

```python
procs = c.procedures.list("mygraph")
# [{"name": "db.recordEpisode", "signature": "(sessionId, kind, text[, payload])", "description": "..."}, ...]

semantic = [p for p in procs if p["name"].startswith("gds.")]
```

Human-readable reference: `docs/procedures-reference.md` in the
engine repo (1400+ lines, every proc's args + YIELD + semantics +
example).

Purpose-specific helpers are exposed on `c.procedures.*` — see
`changes`, `diff`, `change_rate`, `subject_export`, etc. If a helper
doesn't exist for a proc you need, fall back to
`c.cypher(graph, "CALL " + proc_name + "(...)")`.

## Idempotency keys and retries

Requires InvariantDB server 1.4.2 or later. Older servers ignore the key.

An idempotency key makes a write safe to send again. The server runs a write
at most once per key: a resend with the same key and the same request gets
the first answer back, and nothing is written again.

Per call, pass `idempotency_key` (1 to 255 visible ASCII characters). Use a
new key for each logical write and the same key when you resend it:

```python
r = c.cypher(
    "my_graph",
    "CREATE (:Order {id: $id})",
    {"id": "o-42"},
    idempotency_key="order-o-42",
    headers={"x-request-id": request_id},
)
r.replayed  # True when the server answered from the first request
```

`headers` adds request headers, such as `x-request-id` for correlation in the
server logs. The SDK keeps `Authorization`, `User-Agent`, `Accept` and
`Content-Type` and drops them from the map. An `Idempotency-Key` entry in
`headers` is used as the key when `idempotency_key` is not set.

Auto-key mode generates the keys:

```python
c = Client(url, api_key=key, auto_idempotency_key=True)
```

- Each call gets a new UUID key. Two calls never share a key.
- The key is reused only for that call's own retries.
- A per-call `idempotency_key` overrides auto mode.
- Calls to routes without key handling (reads such as `explain`, `search`)
  carry no key.

Methods that take `idempotency_key` and `headers` (keyword-only): `cypher`,
`write`, `write_detailed`, `documents.save`, and the writing procedure
methods `procedures.destroy_subject_key`, `link_derived_from`,
`record_episode`, `consolidate_session`, `apply_salience_decay`,
`reinforce_salience`, `record_belief` and `revise_belief`.

Responses:

- `CypherResult.replayed`, `.idempotency_key`, `.idempotency_key_status`.
  `idempotency_key_status` is `ignored-read` when the statement did not write
  (the key was not used), `unsupported` or `disabled`.
- `write_detailed(graph, ops, ...)` returns a `WriteResult` with `body`,
  `replayed`, `idempotency_key`, `idempotency_key_status`, `request_id`,
  `attempts`, `outcome_retained` and `replay_receipt`. `write` returns only
  the body.
- A replay can come without the first answer. When a resend arrives after the
  first request committed but before the server stored its answer, the server
  returns a commit receipt instead (`outcome_retained: false`). The SDK then
  sets `outcome_retained = False` and `replay_receipt` (a `ReplayReceipt`
  with `committed`, `first_seq`, `last_seq`, `applied_seq`, `message`); a
  `CypherResult` has empty `columns` and `rows` and `last_seq` from the
  receipt. The write ran once; read the data again if you need the rows.

Retries. The SDK resends a request only when it carries a key, with the same
key, up to `max_retries` times (default 2; `0` turns retries off). It resends
after a network error or timeout, 408, 409 `IDEMPOTENCY_KEY_IN_FLIGHT`, 429,
502, 503 and 504, and after an error marked `retry: "ambiguous"`. It waits
`retry_after_ms` or `Retry-After` when the server sends one, otherwise
200 ms, then 400 ms (with jitter, at most 5 s). It does not resend after an
error marked `retryable: false` or `retry: "terminal"`, other 4xx, or 500.
Requests without a key are never resent.

Errors (all have `subcode`, `code`, `retry_after_ms`, `retryable`, `retry`
and `idempotency_key`):

| Error | Status, subcode | Retry |
|---|---|---|
| `IdempotencyKeyReusedError` (subclass of `ConflictError`) | 409 `IDEMPOTENCY_KEY_REUSED`: the key was used for a different request. Nothing ran. | No. Use a new key for a new request. |
| `IdempotencyKeyInFlightError` (subclass of `ConflictError`) | 409 `IDEMPOTENCY_KEY_IN_FLIGHT`: the first request with the key is still running. Nothing ran. | Yes, with the same key after `retry_after_ms` (about 1 s). The SDK does this within `max_retries`. |
| `IdempotencyKeyOutcomeUnknownError` (subclass of `ServerError`) | 503 `IDEMPOTENCY_KEY_OUTCOME_UNKNOWN`: the first request's outcome is known after the graph reopens. Nothing ran. | Yes, with the same key after `retry_after_ms` (about 5 s). |

`ValidationError` with subcode `IDEMPOTENCY_KEY_INVALID` means the server
refused the key's format. The SDK checks the format first and raises
`ValueError` before sending. A server running Cypher writes without the
write-statement lock answers a keyed Cypher write with 400
`IDEMPOTENCY_KEY_UNSUPPORTED`; in auto-key mode the SDK then sends the call
once more without a key, and with an explicit key it raises the error.

After a lost answer that the SDK did not retry, resend the call with the same
key, or ask the server: `GET /graphs/{name}/idempotency-keys/{key}`. Keys are
kept for 24 hours and belong to one graph and one credential.

## Authentication

Pass `api_key="gq_..."` to `Client(...)`. The SDK sends it as
`Authorization: Bearer gq_...` on every request. Open-mode servers (no auth
configured) work with `api_key=None`.

## Errors

All errors inherit from `InvariantDBError`. Catch the specific subclass when
you want to branch on the failure mode:

```python
from invariantdb import Client, NotFoundError, AuthError

try:
    c.graphs.info("does_not_exist")
except NotFoundError:
    ...
except AuthError:
    ...
```

## License

© 2026 InvariantDB Inc. All rights reserved. Proprietary and confidential.
