Metadata-Version: 2.5
Name: surrealdb-orm
Version: 0.21.20
Summary: SurrealDB ORM as 'DJango style' for Python with async support. Works with pydantic validation.
Project-URL: Homepage, https://github.com/EulogySnowfall/SurrealDB-ORM
Project-URL: Documentation, https://github.com/EulogySnowfall/SurrealDB-ORM
Project-URL: Repository, https://github.com/EulogySnowfall/SurrealDB-ORM.git
Project-URL: Issues, https://github.com/EulogySnowfall/SurrealDB-ORM/issues
Author-email: Yannick Croteau <croteau.yannick@gmail.com>
License: # MIT License
        
        Copyright (c) 2025-2026 Yannick Croteau
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Requires-Dist: aiohttp>=3.14.4
Requires-Dist: cbor2<6,>=5.9.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic>=2.10.5
Provides-Extra: all
Requires-Dist: click>=8.1.0; extra == 'all'
Provides-Extra: cli
Requires-Dist: click>=8.1.0; extra == 'cli'
Description-Content-Type: text/markdown

# SurrealDB-ORM (V2 LTS - SurrealDB 2.X)

![Python](https://img.shields.io/badge/python-3.12%2B-blue)
![CI](https://github.com/EulogySnowfall/SurrealDB-ORM/actions/workflows/ci.yml/badge.svg?branch=v2)
[![codecov](https://codecov.io/gh/EulogySnowfall/SurrealDB-ORM/graph/badge.svg?token=XUONTG2M6Z)](https://codecov.io/gh/EulogySnowfall/SurrealDB-ORM)
![GitHub License](https://img.shields.io/github/license/EulogySnowfall/SurrealDB-ORM)

> **V2 LTS Branch** - Maintenance-only for SurrealDB 2.X compatibility. For SurrealDB 3.X, see the [`main` branch](https://github.com/EulogySnowfall/SurrealDB-ORM/tree/main).

<!-- -->

> [!WARNING]
> **Deprecated (since 0.21.0).** This V2 branch is deprecated in favor of
> **[SurrealDB-ORM-lite](https://github.com/EulogySnowfall/SurrealDB-ORM-lite)**, which
> continues to support **SurrealDB 2.x** on top of the now-matured **official SurrealDB
> Python SDK v2** — the SDK improvements are what make a bundled custom SDK unnecessary
> for the 2.x line. This branch will only receive **security and bug fixes** until
> SurrealDB-ORM-lite reaches **v0.40.0 (beta)**, after which it will be retired. New projects
> targeting SurrealDB 2.x should adopt SurrealDB-ORM-lite.

**SurrealDB-ORM** is a Django-style ORM for [SurrealDB](https://surrealdb.com/) with async support, Pydantic validation, and JWT authentication.

**Includes a custom SDK (`surreal_sdk`)** - Zero dependency on the official `surrealdb` package!

---

## Branch Strategy

| Branch                                                                        | SurrealDB Version | Status             | Version |
| ----------------------------------------------------------------------------- | ----------------- | ------------------ | ------- |
| [`main`](https://github.com/EulogySnowfall/SurrealDB-ORM/tree/main)           | 3.X               | Active development | 0.3y.x  |
| [`v2`](https://github.com/EulogySnowfall/SurrealDB-ORM/tree/v2) (this branch) | 2.X               | Deprecated (LTS)¹  | 0.2y.x  |

> ¹ Deprecated since 0.21.0 — see the notice above. Superseded by [SurrealDB-ORM-lite](https://github.com/EulogySnowfall/SurrealDB-ORM-lite).

### V2 LTS Policy

- **No new features** are developed on this branch
- **Security vulnerabilities** and **bug fixes** are actively maintained
- **SurrealDB 2.X compatibility updates** are automatically monitored and applied
- A daily workflow checks for new SurrealDB 2.X releases (< 3.0), runs integration tests, and auto-creates PRs
- Security patches trigger automatic version bumps and PyPI releases

### SurrealDB Compatibility

| ORM version     | Supported SurrealDB     | Tested against |
| --------------- | ----------------------- | -------------- |
| 0.21.8+         | 2.6.x – 2.7.x (< 3.0)   | **v2.7.0**     |
| 0.21.0 – 0.21.7 | 2.6.x                   | v2.6.5         |

For SurrealDB 3.x, use the [`main` branch](https://github.com/EulogySnowfall/SurrealDB-ORM/tree/main) (0.3y.x).

---

## What's New in 0.21.20

**Bug fix release (V2 LTS).** One ORM fix, the `v2` port of #286 from `main`; no new public API.

- **An optional `ForeignKey` is now converted to a record link (#294).** With `ForeignKey("X") | None` or
  `Optional[ForeignKey("X")]`, `save()`, `update()`, `merge()` and `bulk_update()` sent a string, so an
  `option<record<...>>` column rejected the write, and `filter()` (`exact` / `in` / `not_in`, including
  inside `Q`) silently returned no rows for records that exist. Optional foreign keys now behave like
  required ones.
- **Behaviour change:** rows previously written as strings through an optional `ForeignKey` (SCHEMALESS
  table, or a field typed `any`) are no longer matched by a filter on that field, and a field declared
  `option<string>` now rejects the write. The one-time conversion statement is in the [CHANGELOG](CHANGELOG).

## What's New in 0.21.19

**Security release (V2 LTS).** No library code change; the declared `aiohttp` floor moves to `>=3.14.4`.

- **Upgrading `surrealdb-orm` now brings the patched `aiohttp` (#245).** The package declared
  `aiohttp>=3.14.0`, so `pip install -U surrealdb-orm` and existing downstream lockfiles could keep an
  `aiohttp` 3.14.0 – 3.14.3. 3.14.4 is the first release patching eight advisories affecting
  `aiohttp <= 3.14.3`; the new floor also drops 3.14.0 – 3.14.2 and the 11 more advisories fixed in
  3.14.1 – 3.14.3. The full list is in the [CHANGELOG](CHANGELOG).
- **Behaviour change:** an environment pinning `aiohttp < 3.14.4` now fails at resolution time instead of
  installing alongside a vulnerable `aiohttp`. This replaces the 0.21.10 advice to run
  `pip install -U aiohttp` by hand.

## What's New in 0.21.18

**Bug fix release (V2 LTS).** One SDK fix, the `v2` port of the live-stream half of #276 from `main`; no
new public API, but `live_id` changes after a reconnect.

- **`QuerySet.live()` keeps receiving events after a WebSocket reconnect (#277).** With
  `auto_resubscribe=True`, the default, the live query was re-created on the server but the stream's handler
  was not registered under the new id, so every `LiveSelectStream` went silent after its first reconnect. The
  stream now moves to the re-created live query during the resubscribe itself; `on_reconnect` still fires
  with `(old_id, new_id)`.
- **`stop()` after a reconnect ends the re-created live query.** It used to kill the old, dead id, which
  left the re-created query running and re-created it again on every later reconnect. A `stop()` that starts
  while the query is being re-created now ends it too.
- **Behaviour change:** after a reconnect, `LiveSelectStream.live_id` / `LiveModelStream.live_id` report the
  re-created query's id. Code that compares a stored `live_id` should take the new id from `on_reconnect`.
  `LiveSelectManager` still keys streams by the id `watch()` returned: after a reconnect, stop a stream with
  that original id, or use `stop_all()`. See the [CHANGELOG](CHANGELOG).

## What's New in 0.21.17

**Bug fix release (V2 LTS).** One migrations fix, the `v2` port of #248 from `main`; a behaviour change in
what generated migrations emit.

- **A generated `AlterField` no longer resets a field's permissions to `FULL` (#259).** It redefines the
  field with `DEFINE FIELD OVERWRITE`, which replaces the whole definition, and field-level `PERMISSIONS`
  were not carried, so a clause set by hand, by another tool or by a `RawSQL` migration fell back to
  `PERMISSIONS FULL` on the next generated alteration and on its rollback. The diff now reads the clause
  from the database (`FieldState.permissions`) and `AlterField` re-emits it in both directions through the
  new `permissions` / `previous_permissions` arguments.
- **`parse_define_field()` no longer misreads a field's `PERMISSIONS` clause.** A `WHERE` condition naming
  a field such as `type` or `default` was read as that clause (`schema_diff()` raised `ValueError` on
  `type`), and a preceding `ASSERT $value > 0`, or a regex literal holding an apostrophe, folded the clause
  into the assertion.
- **Behaviour change / upgrading:** both arguments are a snapshot of the clause when the migration is
  generated; regenerate (or edit both) if the field's permissions change before you apply it. Migration
  files already on disk carry neither argument and still reset the permissions: add them, or regenerate
  an unapplied migration. See the [CHANGELOG](CHANGELOG).

## What's New in 0.21.16

**Bug fix release (V2 LTS).** One ORM fix, the `v2` port of #257 from `main`; no new public API, but a
behaviour change.

- **`atomic_set_add()` no longer adds duplicates (#261).** It ran `SET field += $value`, and on SurrealDB
  2.x `+=` on an array appends, so two calls with the same value left two copies. It now uses
  `array::add()`, which adds only the values the field does not already hold; a record without the
  field, or holding `NULL`, gets `[value]`.
- **The atomic array helpers no longer lose writes silently.** `atomic_append()`, `atomic_remove()` and
  `atomic_set_add()` returned `[]` when SurrealDB rejected the statement (a write conflict, a failed
  `ASSERT`, an argument of the wrong type), so the write was lost and `@retry_on_conflict` never retried
  the conflict. They now raise `TransactionConflictError` for a conflict and `QueryError` otherwise.
  `atomic_append()` on a record without the field (or holding `NULL`) now writes `[value]` instead of
  being rejected.
- **Behaviour change:** catch `QueryError` / `TransactionConflictError` where you treated `[]` as "not
  written"; `atomic_set_add()` with a list value adds only the missing elements, and on a number or
  string field it now raises. See the [CHANGELOG](CHANGELOG).

## What's New in 0.21.15

**Bug fix release (V2 LTS).** One SDK fix, the `v2` port of #166 from `main`; no public API change.

- **Datetimes read from SurrealDB 2.x decode to `datetime` (#231).** Over CBOR, the default
  protocol, SurrealDB 2.x sends every datetime as a compact `[seconds, nanoseconds]` pair, and the
  SDK only decoded the ISO string form. A `datetime`-annotated model field was converted by the ORM,
  but `raw_query()` / `query()` results, datetimes nested in object or `dict` fields, and live-query
  payloads came back as a list of two ints. A `date` field could not load a datetime stored at
  midnight UTC, and a `dict` field loaded and saved back stored those lists on the server in place
  of its datetimes. They now decode to timezone-aware UTC `datetime` objects, to the microsecond.
- **One undecodable datetime no longer loses the whole message.** An ISO datetime string Python
  cannot parse used to fail the request (`ValueError` over HTTP; over WebSocket the message was
  dropped and the request timed out). It is now returned as sent, and the rest of the message
  decodes. A datetime outside Python's years 1-9999 stays the raw `[seconds, nanoseconds]` list.
- **Behaviour change:** code that read the raw list now receives a `datetime`; remove any
  workaround that converted it yourself. See the [CHANGELOG](CHANGELOG).

## What's New in 0.21.14

**Bug fix release (V2 LTS).** One SDK fix, the `v2` port of #239 from `main`; no ORM API change.

- **`live()` filters match on every supported value type (#246).** SurrealDB 2.x does not evaluate
  bound parameters in a `LIVE SELECT`, so `v2` writes filter values into the query text, and some
  never matched: a `dict` (written as a quoted Python repr), a `Decimal` (a quoted string), a record
  link whose table or id is not a plain identifier (the string id `"123"` read as the number 123),
  and a string holding a backslash or the `$name` of another filter parameter. A naive `datetime`
  made the subscription fail.
  Each value is now written as the literal of what it binds to as a parameter, a naive `datetime`
  is read as UTC, and auto-resubscribe after a reconnect sends the same filter.
- **Binding a `Decimal` or a `datetime.date` works on SurrealDB 2.x.** Both used to make the server
  reject the whole request (unknown CBOR tag), in `save()`, `exec()` filters and `query()`. A
  `Decimal` is sent as SurrealDB's decimal, a `date` as the datetime at midnight UTC. A `Decimal`
  above 79228162514264337593543950335 in magnitude raises `ValueError` before the request is sent.
- **Behaviour change:** a `live()` filter value with no exact SurrealQL literal (`set`, `bytes`,
  `Duration`, an arbitrary object, a non-`str`/`int` `Enum`, a NaN or infinite `Decimal`, a `dict`
  with non-`str` keys) raises `TypeError` instead of being turned into a quoted string; so does a
  NaN or infinite `float`, which used to be written bare (`nan`, `inf`) and is not a number to
  SurrealDB. Convert it first: a `set` to a `list`, an `Enum` to its `.value`.
- **Known limitation, unchanged:** `live(diff=True)` emits `DIFF` after the `WHERE` clause, which
  SurrealDB 2.7.0 rejects as a parse error. See the [CHANGELOG](CHANGELOG) for the full list.

## What's New in 0.21.13

**Bug fix release (V2 LTS).** One SDK fix, backported from `main`; no public API change.

- **HTTP transactions no longer rename parameters a statement did not bind (#247).**
  `HTTPTransaction.commit()` namespaces each statement's keys (`$k` → `$tx_<i>_k`) with a plain
  text replace, so a key also rewrote every longer `$name` it was a prefix of: binding `a` turned
  `$auth.id` into `$tx_0_auth.id`, binding `s` turned `$session.ns` into `$tx_0_session.ns`, and a
  model field whose name is a prefix of `tx_<i>_<an earlier field>` (`t`, `tx`, `tx_`, `tx_<i>`,
  ...) broke the references of the fields bound before it. On SurrealDB 2.x the renamed parameter
  evaluates to `NONE` without an error, so a `WHERE` silently matched nothing and a `SET` silently
  wrote nothing. Exposed over HTTP: `tx.query()` variables, `extra_vars` on `save(tx=)` /
  `merge(tx=)`, `QuerySet.variables()` in atomic `bulk_update()` / `bulk_delete()`, and `save(tx=)`
  of a new record / `relate(tx=)` / `bulk_create(atomic=True)` for a model with such a field. Only
  whole bound `$key` references are renamed now. See the [CHANGELOG](CHANGELOG).
- **Known limitation:** `tx.merge()` over HTTP is still broken, for an unrelated reason (#251): its
  `MERGE { k = $k, ... }` body is not an object to SurrealDB 2.x, so with two or more fields the
  commit fails with a parse error, and with one field `commit()` returns without an error while the
  server rolls the whole transaction back. That covers `update(tx=)`, `merge(tx=)` without a
  `SurrealFunc`, and `save(tx=)` of an already-saved record, on an HTTP connection (WebSocket
  transactions are not affected).

## What's New in 0.21.12

**Security maintenance release (V2 LTS).** No library code changes vs 0.21.11.

- **`certifi` 2026.10.23** in the lock file, clearing GHSA-6q48-6c7w-9gw2 / PYSEC-2026-4320: the
  previous bundle (2026.5.20) still trusted `emSign ECC Root CA - G3`, under which eight
  intermediate CA keys were compromised (and revoked); 2026.10.23 no longer carries that root.
  `certifi` is a transitive dependency (through `httpx`) and this package does not declare it, so
  upgrading `surrealdb-orm` keeps the `certifi` you already have: run `pip install -U certifi` to
  get `>= 2026.10.19`.
- **Backfilled:** 0.21.10 and 0.21.11 (2026-10-20) were auto-released with version files only.
  **0.21.10** — `aiohttp` 3.14.5 (with `yarl` 1.25.1) in the lock file, GHSA-2g87-pp6c-29x5,
  GHSA-494j-54xm-rwp3, GHSA-5wr8-gcqf-gqf3. `aiohttp` is a runtime dependency whose declared floor
  stays `>=3.14.0`: run `pip install -U aiohttp`. **0.21.11** — `multidict` 7.1.0 in the lock file,
  GHSA-54p9-h82j-f925 (transitive, through `aiohttp`). See the [CHANGELOG](CHANGELOG).

## What's New in 0.21.9

**Security maintenance release (V2 LTS).** No library code changes vs 0.21.8.

- **`tornado` 6.5.9 and `urllib3` 2.8.0** in the lock file, clearing 6 advisories (GHSA-chx6-46f5-w4vp,
  GHSA-c2m8-h5v5-343r, GHSA-3hv7-mjh2-fv65; PYSEC-2026-4175/4176/4177). Both are **development-only**
  dependencies (Jupyter tooling, the Docker client used by the test fixtures): they are not part of the
  installed package, so users are not exposed. See the [CHANGELOG](CHANGELOG).

## What's New in 0.21.8

**Compatibility maintenance release (V2 LTS).** No library code changes vs 0.21.7.

- **Tested against SurrealDB v2.7.0.** The pin (`.surrealdb-version`, `devops/docker-compose.yml`)
  moves from v2.6.5 to v2.7.0, and the full unit and integration suites pass unchanged. 2.7.0 is
  2.6.5 plus background reclamation of removed namespace/database data — nothing on the query,
  CBOR or RPC surface the ORM depends on.
- **Supported range is now SurrealDB 2.6.x – 2.7.x.** The minimum is unchanged; there is no need
  to upgrade the server to take this release.

### Maintenance releases 0.21.2 – 0.21.7

| Version | Date       | Content                                                                                  |
| ------- | ---------- | ---------------------------------------------------------------------------------------- |
| 0.21.7  | 2026-09-18 | `anyio` 4.14.2 in the lock file (transitive, via `httpx`). No library changes.           |
| 0.21.6  | 2026-09-07 | Migration fixes — see below.                                                             |
| 0.21.5  | 2026-09-05 | `AlterField`, statement errors, `ForeignKey` coercion — see below.                       |
| 0.21.4  | 2026-09-01 | `tornado` 6.5.8 in the lock file (dev-only). No library changes.                         |
| 0.21.3  | 2026-08-11 | `aiohttp` 3.14.3 in the lock file; `tag-release` CI fix. No library changes.             |
| 0.21.2  | 2026-07-20 | Denied/missing `UPDATE` and `merge()` raise instead of silently no-opping; `istartswith` / `iendswith` are case-insensitive. |

Full details in the [CHANGELOG](CHANGELOG).

---

## What's New in 0.21.6

**Bug-fix maintenance release (V2 LTS)** — three migration fixes ported from the 3.x `main` line,
plus the gaps left by 0.21.5's own ForeignKey backport. Each was reproduced against a live
SurrealDB 2.6.5 on this branch before being ported.

- **Table permissions now reach the database.** The clause was emitted as a second `DEFINE TABLE`,
  which 2.6.x rejects once the table exists.

- **`FULL` is no longer written as a silent deny.** Re-emitting a table defined with
  `FOR select FULL` produced `FOR select WHERE FULL`, which the server accepts and then evaluates
  as a field reference — it resolves to `NONE`, and a world-readable table becomes unreadable with
  no error anywhere.

- **Rolling back a table change no longer drops the table.** The diff reused `CreateTable`, whose
  rollback is `REMOVE TABLE`, so a migration that changed one permission had a rollback that
  destroyed every row — and reported itself reversible.

- **A rollback renders a field exactly as the statement it reverses does.** `backwards()` quoted
  every `DEFAULT` and dropped the `VALUE` clause entirely, which stopped an `Encrypted` column
  hashing and stored plaintext from then on.

- **The JSON protocol no longer loses a record id's type.** `str(RecordId)` renders `t:123`, the
  *numeric* record, while CBOR wrote the string one — two different rows, no error, on the very
  protocol 0.21.5's ForeignKey backport set out to repair.

### Behaviour change

`makemigrations` no longer writes irreversible removals by default. A `RELATE` edge table, an
analyzer, the migration history, or a module that simply was not imported all look identical to a
deleted model, so those operations are reported and held back:

```
$ surreal-orm makemigrations
Skipped 2 irreversible operation(s) with no model to justify them:
  - Drop table has_player
  - Remove analyzer english
Pass --drop-missing to apply them (irreversible).
```

### Known limitation

Migration files generated before this release carry no `previous_value`, so their rollback still
drops the `VALUE` clause and nothing warns. Regenerate them before relying on it.

---

## What's New in 0.21.5

**Bug-fix maintenance release (V2 LTS).** Four correctness fixes backported from the 3.x
`main` line, each reproduced against a live SurrealDB 2.6.5 on this branch before porting.

- **`AlterField` never altered anything.** It emitted a plain `DEFINE FIELD`, which 2.6.x
  rejects for an existing field (`The field 'a' already exists`), leaving the definition
  untouched — every alter and every rollback was a silent no-op. Both emit
  `DEFINE FIELD OVERWRITE` now.
- **The executor treated a rejected statement as success.** `client.query()` raises only on
  RPC-level failures; a rejected statement rides back inside a *successful* RPC as
  `status: ERR`. `migrate()`, `rollback()` and `upgrade()` now raise `MigrationStatementError`.
- **`ManyToMany` / `Relation` were emitted as `any` columns**, and a `str | None` field
  reached the database as a **required** column. Graph relations are virtual and now define
  no column; `nullable` travels into `AddField` / `AlterField`.
- **`ForeignKey` values never became record links.** Writes to a `record<>` column were
  rejected, and whole-record filters returned `[]` for rows that existed — a wrong answer,
  not an error:

  ```python
  await Article.objects().filter(author="authors:alice").exec()  # was []
  ```

  Every write path and whole-record lookup converts now; the model instance, `"table:id"`,
  a bare ID and a `RecordId` are interchangeable.
- **`makemigrations` diffed against an empty schema**, re-emitting every table on every run.
  It reads the current schema from the database now.

> **Behaviour change.** `makemigrations` contacts the database by default and fails with a
> clear error when it cannot read the schema. Use `--no-from-db` for a genuine first
> migration or when working offline.

**New:** `to_record_id()`, `record_link_to_str()`, `instance.record_id`,
`get_foreign_key_targets()`, `get_foreign_key_columns()`, `MigrationStatementError`,
`AddField.from_field_state()` / `AlterField.from_field_states()`.

The SurrealDB 3.0 `REFERENCE` / `ON DELETE` work is **not** backported — record references
are a 3.0 feature.

## What's New in 0.21.1

> **Security maintenance release.** No library code changes vs 0.21.0.

- **`cbor2` stays pinned `<6`** - Rejected the Dependabot proposal to widen the requirement to
  `>=5.9.0,<7` (PR #117), which would re-allow cbor2 **6.x**. 6.x changed the custom-tag encoding and
  breaks SurrealDB 2.x CBOR; the 6.x compatibility fix only landed on `main` (3.x) and was not backported
  here. The security floor (`>=5.9.0`) is already in place. Dependabot now ignores cbor2 major bumps on `v2`.
- **CI: v2 LTS releases are no longer marked GitHub "Latest" (#113)** and the **retirement threshold is
  clarified as SurrealDB-ORM-lite v0.40.0 (beta) (#114)**.

---

## What's New in 0.21.0

> **Deprecation release.** This branch is now deprecated in favor of
> [SurrealDB-ORM-lite](https://github.com/EulogySnowfall/SurrealDB-ORM-lite). It remains
> under security/bug-fix maintenance until SurrealDB-ORM-lite reaches v0.40.0 (beta).

- **Deprecation notice** - README banner + a runtime `DeprecationWarning` on `import surreal_orm`,
  pointing to SurrealDB-ORM-lite (which supports SurrealDB 2.x via the official SurrealDB Python SDK v2).
- **Security: dependency upgrades** - Cleared 21 known CVEs across 7 packages. Runtime floor raised to
  `aiohttp>=3.14.0`; `cbor2` constrained to `>=5.9.0,<6` so installs stay on a non-vulnerable 5.x and
  never pull the breaking 6.0 on SurrealDB 2.x.
- **Fix: 401 clock-skew retry (issue #101)** - The SDK HTTP layer now re-mints the JWT and retries on a
  transient `401` caused by a freshly-minted token whose `nbf` claim is briefly in the future under host
  clock skew (e.g. WSL2 backward clock jumps). Backported from `main`.
- **Fix: transaction rollback test on SurrealDB 2.6.x** - `SELECT` on a non-existent table returns an
  `ERR` ("table does not exist") on 2.6.x; the rollback assertion now treats that as proof no records
  were created.
- **Validated against SurrealDB v2.6.5** - Full unit + integration suite (362 integration tests) green.

---

## What's New in 0.20.0

Version 0.20.0 is the first release on the V2 LTS branch, establishing the maintenance baseline for SurrealDB 2.X compatibility.

- **LTS branch setup** - Automated security monitoring for SurrealDB 2.X releases
- **Branch protection** - CI/CD pipeline targeting V2 branch with auto-merge for dependency updates
- **Version bump** - From 0.14.4 to 0.20.0 to distinguish the LTS maintenance line

---

## What's New in 0.14.4

### Fix: Datetime Serialization Round-Trip

Python `datetime` objects now survive `save()` / `merge()` round-trips as native SurrealDB datetime values. Previously, datetimes were serialized as plain ISO strings, causing silent type mismatches with `TYPE datetime` schema fields.

```python
from datetime import UTC, datetime

class Event(BaseSurrealModel):
    model_config = SurrealConfigDict(table_name="events")
    occurred_at: datetime | None = None

event = Event(occurred_at=datetime.now(UTC))
await event.save()  # datetime now correctly encoded via CBOR datetime tag

loaded = await Event.objects().get(event.id)
assert isinstance(loaded.occurred_at, datetime)  # True — no more plain strings
```

### Generic `QuerySet[T]` — Full Type Inference

`QuerySet` is now generic. All terminal methods return properly typed model instances:

```python
# Before (v0.14.3): user is Any — no type inference
user = await User.objects().get("user:alice")

# After (v0.14.4): user is User — full IDE autocomplete and mypy checking
user = await User.objects().get("user:alice")
user.name  # IDE knows this is a str
```

### Typed `get_related()` via `@overload`

Return type is now inferred from the `model_class` parameter:

```python
# Returns list[Book] — fully typed
books = await author.get_related("wrote", direction="out", model_class=Book)

# Returns list[dict[str, Any]] — raw dicts when no model_class
raw = await author.get_related("wrote", direction="out")
```

---

## What's New in 0.14.3

### Fix: Large Nested Dict Parameter Binding (Issue #55)

SurrealDB v2.6's CBOR parameter binding silently drops complex nested structures — dicts with nested dicts/lists arrive as `{}` on the server. Two fixes:

- **`save()` auto-routing** — Complex nested data is now automatically routed through a SET-clause query path where each field is bound as a separate variable, avoiding the problematic single-object CBOR binding.

  ```python
  class GameSession(BaseSurrealModel):
      model_config = SurrealConfigDict(table_name="game_sessions")
      game_state: dict | None = None  # Large nested dict (~20KB+)

  session = GameSession(game_state={"players": [...], "deck": [...], "nested": {...}})
  await session.save()  # Automatically uses SET-clause path
  ```

- **`raw_query(inline_dicts=True)`** — New parameter that inlines complex dict/list variables as JSON in the query string, bypassing CBOR parameter binding entirely.

  ```python
  large_state = {"players": [...], "deck": [...], "melds": {...}}
  results = await GameSession.raw_query(
      "UPSERT game_sessions:test SET game_state = $state",
      variables={"state": large_state},
      inline_dicts=True,  # Inlines $state as JSON in the query
  )
  ```

---

## What's New in 0.14.2

### Production Fixes

Five improvements from real production usage (FastAPI + SurrealDB, multi-pod K8s):

- **CBOR None → NONE Encoding** — Python `None` is now correctly encoded as SurrealDB `NONE` (absent field) instead of `NULL`. Fixes `option<T>` rejection on SCHEMAFULL tables and large nested dict parameter binding failures.

- **Token Validation Cache** — `validate_token()` now uses an in-memory TTL cache (default 300s) to avoid ephemeral HTTP connections on every call. New `validate_token_local()` decodes JWT locally without any network call.

  ```python
  # Cached validation — no network call on cache hit
  record_id = await User.validate_token(token)

  # Local JWT decode — zero network calls (trusted backend only)
  record_id = User.validate_token_local(token)

  # Cache management
  User.configure_token_cache(ttl=600)
  User.invalidate_token_cache()
  ```

- **`validate_assignment=True`** — Pydantic now auto-validates field assignments, so `event.started_at = "2026-02-13T10:00:00Z"` is auto-coerced to `datetime`.

- **`flexible_fields` Config** — Discoverable way to mark fields as `FLEXIBLE TYPE` in migrations:

  ```python
  class GameSession(BaseSurrealModel):
      model_config = SurrealConfigDict(
          table_name="game_sessions",
          schema_mode="SCHEMAFULL",
          flexible_fields=["game_state", "metadata"],
      )
      game_state: dict | None = None   # → DEFINE FIELD FLEXIBLE TYPE option<object>
  ```

---

## What's New in 0.14.1

### Typed Functions API Documentation

- **Typed Functions API in Notebook 08** — Added comprehensive `db.fn.*` examples covering math, string, time, crypto, and array functions, plus dynamic namespace resolution and SQL inspection. Notebook reordered from simple to complex.

  ```python
  db = await SurrealDBConnectionManager.get_client()

  sqrt = await db.fn.math.sqrt(144)             # 12.0
  upper = await db.fn.string.uppercase("hello")  # "HELLO"
  now = await db.fn.time.now()                    # server timestamp
  sha = await db.fn.crypto.sha256("data")         # hash string
  arr = await db.fn.array.distinct([1, 2, 2, 3])  # [1, 2, 3]
  ```

---

## What's New in 0.14.0

### Testing & Developer Experience (Alpha → Beta)

This release transitions the ORM from **Alpha to Beta** and adds first-class testing and debugging utilities.

- **Test Fixtures** — Declarative test data with automatic cleanup

  ```python
  from surreal_orm.testing import SurrealFixture, fixture

  @fixture
  class UserFixtures(SurrealFixture):
      alice = User(name="Alice", role="admin")
      bob = User(name="Bob", role="player")

  async with UserFixtures.load() as fixtures:
      assert fixtures.alice.get_id() is not None
  # Automatic cleanup on exit
  ```

- **Model Factories** — Factory Boy-style data generation (zero dependencies)

  ```python
  from surreal_orm.testing import ModelFactory, Faker

  class UserFactory(ModelFactory):
      class Meta:
          model = User

      name = Faker("name")
      email = Faker("email")
      age = Faker("random_int", min=18, max=80)
      role = "player"

  user = UserFactory.build()            # In-memory (unit tests)
  user = await UserFactory.create()     # Saved to DB (integration tests)
  users = await UserFactory.create_batch(50)
  ```

- **QueryLogger** — Profile and debug ORM queries

  ```python
  from surreal_orm.debug import QueryLogger

  async with QueryLogger() as logger:
      users = await User.objects().filter(role="admin").exec()
      await user.save()

  for q in logger.queries:
      print(f"{q.sql} — {q.duration_ms:.1f}ms")
  print(f"Total: {logger.total_queries} queries, {logger.total_ms:.1f}ms")
  ```

- **15 Jupyter Notebooks** — Comprehensive examples covering all ORM features, from setup to testing

---

## What's New in 0.13.0

### Events, Geospatial, Materialized Views & TYPE RELATION

- **DEFINE EVENT** — Server-side triggers in migrations

  ```python
  from surreal_orm import DefineEvent

  DefineEvent(
      name="email_audit", table="users",
      when="$before.email != $after.email",
      then="CREATE audit_log SET table = 'user', record = $value.id, action = $event",
  )
  ```

- **Geospatial Fields** — Typed geometry fields and proximity queries

  ```python
  from surreal_orm.fields import PointField, PolygonField
  from surreal_orm.geo import GeoDistance

  class Store(BaseSurrealModel):
      name: str
      location: PointField          # geometry<point>
      delivery_area: PolygonField   # geometry<polygon>

  # Proximity search: stores within 5km
  nearby = await Store.objects().nearby(
      "location", (-73.98, 40.74), max_distance=5000
  ).exec()

  # Distance annotation
  stores = await Store.objects().annotate(
      dist=GeoDistance("location", (-73.98, 40.74)),
  ).order_by("dist").limit(10).exec()
  ```

- **Materialized Views** — Read-only models backed by `DEFINE TABLE ... AS SELECT`

  ```python
  class OrderStats(BaseSurrealModel):
      model_config = SurrealConfigDict(
          table_name="order_stats",
          view_query="SELECT status, count() AS total, math::sum(amount) AS revenue FROM orders GROUP BY status",
      )
      status: str
      total: int
      revenue: float

  # Auto-maintained by SurrealDB — read-only queries only
  stats = await OrderStats.objects().all()
  await stats[0].save()  # TypeError: Cannot modify materialized view
  ```

- **TYPE RELATION** — Enforce graph edge constraints in migrations

  ```python
  class Likes(BaseSurrealModel):
      model_config = SurrealConfigDict(
          table_type=TableType.RELATION,
          relation_in="person",
          relation_out=["blog_post", "book"],
          enforced=True,
      )
  ```

---

## What's New in 0.12.0

### Vector Search & Full-Text Search

- **Vector Similarity Search** — KNN search with HNSW indexes for AI/RAG pipelines

  ```python
  from surreal_orm.fields import VectorField

  class Document(BaseSurrealModel):
      title: str
      embedding: VectorField[1536]

  # KNN similarity search (top 10 nearest neighbours)
  docs = await Document.objects().similar_to(
      "embedding", query_vector, limit=10
  ).exec()

  # Combined with filters
  docs = await Document.objects().filter(
      category="science"
  ).similar_to("embedding", query_vector, limit=5).exec()
  ```

- **Full-Text Search** — BM25 scoring, highlighting, and multi-field search

  ```python
  from surreal_orm import SearchScore, SearchHighlight

  results = await Post.objects().search(title="quantum").annotate(
      relevance=SearchScore(0),
      snippet=SearchHighlight("<b>", "</b>", 0),
  ).exec()
  ```

- **Hybrid Search** — Reciprocal Rank Fusion combining vector + FTS

  ```python
  results = await Document.objects().hybrid_search(
      vector_field="embedding", vector=query_vec, vector_limit=20,
      text_field="content", text_query="machine learning", text_limit=20,
  )
  ```

- **Analyzer & Index Operations** — `DefineAnalyzer`, HNSW and BM25 index support in migrations

---

## What's New in 0.11.0

### Advanced Queries & Caching

- **Subqueries** — Embed a QuerySet as a filter value in another QuerySet
- **Query Cache** — TTL-based caching with automatic invalidation on writes
- **Prefetch Objects** — Fine-grained control over related data prefetching

---

## What's New in 0.10.0

### Schema Introspection & Multi-Database Support

- **Schema Introspection** - Generate Python model code from an existing SurrealDB database

  ```python
  from surreal_orm import generate_models_from_db, schema_diff

  # Generate Python model code from existing database
  code = await generate_models_from_db(output_path="models.py")

  # Compare Python models against live database schema
  operations = await schema_diff(models=[User, Order, Product])
  for op in operations:
      print(op)  # Migration operations needed to sync
  ```

  - `DatabaseIntrospector` parses `INFO FOR DB` / `INFO FOR TABLE` into `SchemaState`
  - `ModelCodeGenerator` converts `SchemaState` to fully-typed Python model source code
  - Handles generic types (`array<string>`, `option<int>`, `record<users>`), VALUE/ASSERT expressions, encrypted fields, FLEXIBLE, READONLY
  - CLI: `surreal-orm inspectdb` and `surreal-orm schemadiff`

- **Multi-Database Support** - Named connection registry for routing models to different databases

  ```python
  from surreal_orm import SurrealDBConnectionManager

  # Register named connections
  SurrealDBConnectionManager.add_connection("default", url=..., ns=..., db=...)
  SurrealDBConnectionManager.add_connection("analytics", url=..., ns=..., db=...)

  # Model-level routing
  class AnalyticsEvent(BaseSurrealModel):
      model_config = SurrealConfigDict(connection="analytics")

  # Context manager override (async-safe)
  async with SurrealDBConnectionManager.using("analytics"):
      events = await AnalyticsEvent.objects().all()
  ```

  - `ConnectionConfig` frozen dataclass for immutable connection settings
  - `using()` async context manager with `contextvars` for async safety
  - Full backward compatibility: `set_connection()` delegates to `add_connection("default", ...)`
  - `list_connections()`, `get_config()`, `remove_connection()` registry management

---

## What's New in 0.9.0

### ORM Real-time Features: Live Models + Change Feed

- **Live Models** - Real-time subscriptions at the ORM level yielding typed Pydantic model instances

  ```python
  from surreal_orm import LiveAction

  async with User.objects().filter(role="admin").live() as stream:
      async for event in stream:
          match event.action:
              case LiveAction.CREATE:
                  print(f"New admin: {event.instance.name}")
              case LiveAction.UPDATE:
                  print(f"Updated: {event.instance.email}")
              case LiveAction.DELETE:
                  print(f"Removed: {event.record_id}")
  ```

  - `ModelChangeEvent[T]` with typed `instance`, `action`, `record_id`, `changed_fields`
  - Full QuerySet filter integration (WHERE clause + parameterized variables)
  - `auto_resubscribe=True` for seamless WebSocket reconnect recovery
  - `diff=True` for receiving only changed fields

- **Change Feed Integration** - HTTP-based CDC for event-driven microservices

  ```python
  async for event in User.objects().changes(since="2026-01-01"):
      await publish_to_queue({
          "type": f"user.{event.action.value.lower()}",
          "data": event.raw,
      })
  ```

  - Stateless, resumable with cursor tracking
  - Configurable `poll_interval` and `batch_size`
  - No WebSocket required (works over HTTP)

- **`post_live_change` signal** - Fires for external database changes (separate from local CRUD signals)

  ```python
  from surreal_orm import post_live_change, LiveAction

  @post_live_change.connect(Player)
  async def on_player_change(sender, instance, action, **kwargs):
      if action == LiveAction.CREATE:
          await ws_manager.broadcast({"type": "player_joined", "name": instance.name})
  ```

- **WebSocket Connection Manager** - `get_ws_client()` creates a lazy WebSocket connection alongside HTTP

---

## What's New in 0.8.0

### Auth Module Fixes + Computed Fields

- **Ephemeral Auth Connections** (Critical) - `signup()`, `signin()`, and `authenticate_token()` no longer corrupt the singleton connection. They use isolated ephemeral connections.

- **Configurable Access Name** - Access name is configurable via `access_name` in `SurrealConfigDict` (was hardcoded to `{table}_auth`)

- **`signup()` Returns Token** - Now returns `tuple[Self, str]` (user + JWT token), matching `signin()`

  ```python
  user, token = await User.signup(email="alice@example.com", password="secret", name="Alice")
  ```

- **`authenticate_token()` Fixed + `validate_token()`** - Fixed token validation with new `validate_token()` lightweight method

  ```python
  result = await User.authenticate_token(token)  # Full: (user, record_id)
  record_id = await User.validate_token(token)    # Lightweight: just record_id
  ```

- **Computed Fields** - Server-side computed fields using SurrealDB's `DEFINE FIELD ... VALUE <expression>`

  ```python
  from surreal_orm import Computed

  class User(BaseSurrealModel):
      first_name: str
      last_name: str
      full_name: Computed[str] = Computed("string::concat(first_name, ' ', last_name)")

  class Order(BaseSurrealModel):
      items: list[dict]
      discount: float = 0.0
      subtotal: Computed[float] = Computed("math::sum(items.*.price * items.*.qty)")
      total: Computed[float] = Computed("subtotal * (1 - discount)")
  ```

  - `Computed[T]` defaults to `None` (server computes the value)
  - Auto-excluded from `save()`/`merge()` via `get_server_fields()`
  - Migration introspector auto-generates `DEFINE FIELD ... VALUE <expression>`

---

## What's New in 0.7.0

### Performance & Developer Experience

- **`merge(refresh=False)`** - Skip the extra SELECT round-trip for fire-and-forget updates

  ```python
  await user.merge(last_seen=SurrealFunc("time::now()"), refresh=False)
  ```

- **`call_function()`** - Invoke custom SurrealDB stored functions from the ORM

  ```python
  result = await SurrealDBConnectionManager.call_function(
      "acquire_game_lock", params={"table_id": tid, "pod_id": pid},
  )
  result = await GameTable.call_function("release_game_lock", params={...})
  ```

- **`extra_vars` on `save()`** - Bind additional query variables for SurrealFunc expressions

  ```python
  await user.save(
      server_values={"password_hash": SurrealFunc("crypto::argon2::generate($password)")},
      extra_vars={"password": raw_password},
  )
  ```

- **`fetch()` / FETCH clause** - Resolve record links inline to prevent N+1 queries

  ```python
  posts = await Post.objects().fetch("author", "tags").exec()
  # Generates: SELECT * FROM posts FETCH author, tags;
  ```

- **`remove_all_relations()` list support** - Remove multiple relation types in one call

  ```python
  await table.remove_all_relations(["has_player", "has_action"], direction="out")
  ```

---

## What's New in 0.6.0

### Query Power, Security & Server-Side Functions

- **Q Objects for Complex Queries** - Django-style composable query expressions with OR/AND/NOT

  ```python
  from surreal_orm import Q

  # OR query
  users = await User.objects().filter(
      Q(name__contains="alice") | Q(email__contains="alice"),
  ).exec()

  # NOT + mixed with regular kwargs
  users = await User.objects().filter(
      ~Q(status="banned"), role="admin",
  ).order_by("-created_at").exec()
  ```

- **Parameterized Filters (Security)** - All filter values are now query variables (`$_fN`)
  - Prevents SQL injection by never embedding values in query strings
  - Existing `$variable` references via `.variables()` still work

- **SurrealFunc for Server-Side Functions** - Embed SurrealQL expressions in save/update

  ```python
  from surreal_orm import SurrealFunc

  await player.save(server_values={"joined_at": SurrealFunc("time::now()")})
  await player.merge(last_ping=SurrealFunc("time::now()"))
  ```

- **`remove_all_relations()`** - Bulk relation deletion with direction support

  ```python
  await table.remove_all_relations("has_player", direction="out")
  await user.remove_all_relations("follows", direction="both")
  ```

- **Django-style `-field` Ordering** - Shorthand for descending order

  ```python
  users = await User.objects().order_by("-created_at").exec()
  ```

- **Bug Fix: `isnull` Lookup** - `filter(field__isnull=True)` now generates `IS NULL` instead of `IS True`

---

## What's New in 0.5.x

### v0.5.9 - Concurrent Safety, Relation Direction & Array Filtering

- **Atomic Array Operations** - Server-side array mutations avoiding read-modify-write conflicts
  - `atomic_append()`, `atomic_remove()`, `atomic_set_add()` class methods
  - Ideal for multi-pod K8s deployments with concurrent workers

  ```python
  # No more transaction conflicts on concurrent array updates:
  await Event.atomic_set_add(event_id, "processed_by", pod_id)
  ```

- **Transaction Conflict Retry** - `retry_on_conflict()` decorator with exponential backoff + jitter
  - `TransactionConflictError` exception for conflict detection

  ```python
  from surreal_orm import retry_on_conflict

  @retry_on_conflict(max_retries=5)
  async def process_event(event_id, pod_id):
      await Event.atomic_set_add(event_id, "processed_by", pod_id)
  ```

- **Relation Direction Control** - `reverse` parameter on `relate()` and `remove_relation()`

  ```python
  # Reverse: users:xyz -> created -> game_tables:abc
  await table.relate("created", creator, reverse=True)
  ```

- **New Query Lookup Operators** - Server-side array filtering
  - `not_contains` (`CONTAINSNOT`), `containsall` (`CONTAINSALL`), `containsany` (`CONTAINSANY`), `not_in` (`NOT IN`)

  ```python
  events = await Event.objects().filter(processed_by__not_contains=pod_id).exec()
  ```

### v0.5.8 - Around Signals (Generator-based middleware)

- **Around Signals** - Generator-based middleware pattern for wrapping DB operations
  - `around_save`, `around_delete`, `around_update`
  - Shared state between before/after phases (local variables)
  - Guaranteed cleanup with `try/finally`

  ```python
  from surreal_orm import around_save

  @around_save.connect(Player)
  async def time_save(sender, instance, created, **kwargs):
      start = time.time()
      yield  # save happens here
      print(f"Saved {instance.id} in {time.time() - start:.3f}s")

  @around_delete.connect(Player)
  async def delete_with_lock(sender, instance, **kwargs):
      lock = await acquire_lock(instance.id)
      try:
          yield  # delete happens while lock is held
      finally:
          await release_lock(lock)  # Always runs
  ```

  **Execution order:** `pre_* → around(before) → DB → around(after) → post_*`

### v0.5.7 - Model Signals

- **Django-style Model Signals** - Event hooks for model lifecycle operations
  - `pre_save`, `post_save` - Before/after save operations
  - `pre_delete`, `post_delete` - Before/after delete operations
  - `pre_update`, `post_update` - Before/after update/merge operations

  ```python
  from surreal_orm import post_save, Player

  @post_save.connect(Player)
  async def on_player_saved(sender, instance, created, **kwargs):
      if instance.is_ready:
          await ws_manager.broadcast({"type": "player_ready", "id": instance.id})
  ```

### v0.5.6 - Relation Query ID Escaping Fix

- **Fixed ID escaping in relation queries** - When using `get_related()`, `RelationQuerySet`, or graph traversal with IDs starting with digits, queries now properly escape the IDs with backticks, preventing parse errors.

### v0.5.5.3 - RecordId Conversion Fix

- **Fixed RecordId objects in foreign key fields** - When using CBOR protocol, fields like `user_id`, `table_id` are now properly converted to `"table:id"` strings instead of raw RecordId objects, preventing Pydantic validation errors.

### v0.5.5.2 - Datetime Regression Fix

- **Fixed datetime_type Pydantic validation error** - v0.5.5.1 introduced a regression where records with datetime fields failed validation, causing `from_db()` to return dicts instead of model instances
- **New `_preprocess_db_record()` method** - Properly handles datetime parsing and RecordId conversion before Pydantic validation

### v0.5.5.1 - Critical Bug Fixes

- **Record ID escaping** - IDs starting with digits (e.g., `7abc123`) now properly escaped with backticks
- **CBOR for HTTP connections** - HTTP connections now default to CBOR protocol, fixing `data:` prefix issues
- **`get()` full ID format** - `QuerySet.get("table:id")` now correctly parses and queries
- **`get_related()` direction="in"** - Fixed to return actual related records instead of empty results
- **`update()` table name** - Fixed bug where custom `table_name` was ignored

### v0.5.5 - CBOR Protocol & Field Aliases

- **CBOR Protocol (Default)** - Binary protocol for WebSocket connections
  - `cbor2` is now a **required dependency**
  - CBOR is the **default protocol** for WebSocket (fixes `data:` prefix string issues)
  - Aligns with official SurrealDB SDK behavior
- **`unset_connection_sync()`** - Synchronous version for non-async cleanup contexts
- **Field Alias Support** - Map Python field names to different DB column names
  - Use `Field(alias="db_column")` to store under a different name in DB

### v0.5.4 - API Improvements

- **Record ID format handling** - `QuerySet.get()` accepts both `"abc123"` and `"table:abc123"`
- **`remove_relation()` accepts string IDs** - Pass string IDs instead of model instances
- **`raw_query()` class method** - Execute arbitrary SurrealQL from model class

### v0.5.3.3 - Bug Fix

- **`from_db()` fields_set fix** - Fixed bug where DB-loaded fields were incorrectly included in updates via `exclude_unset=True`

### v0.5.3.2 - Critical Bug Fix

- **QuerySet table name fix** - Fixed critical bug where QuerySet used class name instead of `table_name` from config
- **`QuerySet.get()` signature** - Now accepts `id=` keyword argument in addition to positional `id_item`

### v0.5.3.1 - Bug Fixes

- **Partial updates for persisted records** - `save()` now uses `merge()` for already-persisted records, only sending modified fields
- **datetime parsing** - `_update_from_db()` now parses ISO 8601 strings to `datetime` objects automatically
- **`_db_persisted` flag** - Internal tracking to distinguish new vs persisted records

### v0.5.3 - ORM Improvements

- **Upsert save behavior** - `save()` now uses `upsert` for new records with ID (idempotent, Django-like)
- **`server_fields` config** - Exclude server-generated fields (created_at, updated_at) from saves
- **`merge()` returns self** - Now returns the updated model instance instead of None
- **`save()` updates self** - Updates original instance attributes instead of returning new object
- **NULL values fix** - `exclude_unset=True` now works correctly after loading from DB

### v0.5.2 - Bug Fixes & FieldType Improvements

- **FieldType enum** - Enhanced migration type system with `generic()` and `from_python_type()` methods
- **datetime serialization** - Proper JSON encoding for datetime, date, time, Decimal, UUID
- **Fluent API** - `connect()` now returns `self` for method chaining
- **Session cleanup** - WebSocket callback tasks properly tracked and cancelled
- **Optional fields** - `exclude_unset=True` prevents None from overriding DB defaults
- **Parameter alias** - `username` parameter alias for `user` in ConnectionManager

### v0.5.1 - Security Workflows

- **Dependabot integration** - Automatic dependency security updates
- **Auto-merge** - Dependabot PRs merged after CI passes
- **SurrealDB monitoring** - Integration tests on new SurrealDB releases

### v0.5.0 - Real-time SDK Enhancements

- **Live Select Stream** - Async iterator pattern for real-time changes
  - `async with db.live_select("table") as stream: async for change in stream:`
  - `LiveChange` dataclass with `record_id`, `action`, `result`, `changed_fields`
  - WHERE clause support with parameterized queries
- **Auto-Resubscribe** - Automatic reconnection after WebSocket disconnect
  - `auto_resubscribe=True` parameter for seamless K8s pod restart recovery
  - `on_reconnect(old_id, new_id)` callback for tracking ID changes
- **Typed Function Calls** - Pydantic/dataclass return type support
  - `await db.call("fn::my_func", params={...}, return_type=MyModel)`

### v0.4.0 - Relations & Graph

- **Relations & Graph Traversal** - Django-style relation definitions with SurrealDB graph support
  - `ForeignKey`, `ManyToMany`, `Relation` field types
  - Relation operations: `add()`, `remove()`, `set()`, `clear()`, `all()`, `filter()`, `count()`
  - Model methods: `relate()`, `remove_relation()`, `get_related()`
  - QuerySet extensions: `select_related()`, `prefetch_related()`, `traverse()`, `graph_query()`

---

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
  - [Using the SDK (Recommended)](#using-the-sdk-recommended)
  - [Using the ORM](#using-the-orm)
- [SDK Features](#sdk-features)
  - [Connections](#connections)
  - [Transactions](#transactions)
  - [Typed Functions](#typed-functions)
  - [Live Queries](#live-queries)
- [ORM Features](#orm-features)
- [CLI Commands](#cli-commands)
- [Documentation](#documentation)
- [Contributing](#contributing)

---

## Installation

```bash
# Basic installation (includes CBOR support)
pip install surrealdb-orm

# With CLI support
pip install surrealdb-orm[cli]
```

**Requirements:** Python 3.12+ | SurrealDB 2.6.x – 2.7.x (< 3.0; tested against v2.7.0)

**Included:** `pydantic`, `httpx`, `aiohttp`, `cbor2` (CBOR is the default protocol for WebSocket)

---

## Quick Start

### Using the SDK (Recommended)

```python
from surreal_sdk import SurrealDB

async def main():
    # HTTP connection (stateless, ideal for microservices)
    async with SurrealDB.http("http://localhost:8000", "namespace", "database") as db:
        await db.signin("root", "root")

        # CRUD operations
        user = await db.create("users", {"name": "Alice", "age": 30})
        users = await db.query("SELECT * FROM users WHERE age > $min", {"min": 18})

        # Atomic transactions
        async with db.transaction() as tx:
            await tx.create("accounts:alice", {"balance": 1000})
            await tx.create("accounts:bob", {"balance": 500})
            # Auto-commit on success, auto-rollback on exception

        # Built-in functions with typed API
        result = await db.fn.math.sqrt(16)  # Returns 4.0
        now = await db.fn.time.now()        # Current timestamp
```

### Using the ORM

```python
from surreal_orm import BaseSurrealModel, SurrealDBConnectionManager

# 1. Define your model
class User(BaseSurrealModel):
    id: str | None = None
    name: str
    email: str
    age: int = 0

# 2. Configure connection
SurrealDBConnectionManager.set_connection(
    url="http://localhost:8000",
    user="root",
    password="root",
    namespace="myapp",
    database="main",
)

# 3. CRUD operations
user = User(name="Alice", email="alice@example.com", age=30)
await user.save()

users = await User.objects().filter(age__gte=18).order_by("name").limit(10).exec()
```

---

## SDK Features

### Connections

| Type          | Use Case                 | Features                 |
| ------------- | ------------------------ | ------------------------ |
| **HTTP**      | Microservices, REST APIs | Stateless, simple        |
| **WebSocket** | Real-time apps           | Live queries, persistent |
| **Pool**      | High-throughput          | Connection reuse         |

```python
from surreal_sdk import SurrealDB, HTTPConnection, WebSocketConnection

# HTTP (stateless)
async with SurrealDB.http("http://localhost:8000", "ns", "db") as db:
    await db.signin("root", "root")

# WebSocket (stateful, real-time)
async with SurrealDB.ws("ws://localhost:8000", "ns", "db") as db:
    await db.signin("root", "root")
    await db.live("orders", callback=on_order_change)

# Connection Pool
async with SurrealDB.pool("http://localhost:8000", "ns", "db", size=10) as pool:
    await pool.set_credentials("root", "root")
    async with pool.acquire() as conn:
        await conn.query("SELECT * FROM users")
```

### Transactions

Atomic transactions with automatic commit/rollback:

```python
# WebSocket: Immediate execution with server-side transaction
async with db.transaction() as tx:
    await tx.update("players:abc", {"is_ready": True})
    await tx.update("game_tables:xyz", {"ready_count": "+=1"})
    # Statements execute immediately
    # COMMIT on success, CANCEL on exception

# HTTP: Batched execution (all-or-nothing)
async with db.transaction() as tx:
    await tx.create("orders:1", {"total": 100})
    await tx.create("payments:1", {"amount": 100})
    # Statements queued, executed atomically at commit
```

**Transaction Methods:**

- `tx.query(sql, vars)` - Execute raw SurrealQL
- `tx.create(thing, data)` - Create record
- `tx.update(thing, data)` - Replace record
- `tx.delete(thing)` - Delete record
- `tx.relate(from, edge, to)` - Create graph edge
- `tx.commit()` - Explicit commit
- `tx.rollback()` - Explicit rollback

### Typed Functions

Fluent API for SurrealDB functions:

```python
# Built-in functions (namespace::function)
sqrt = await db.fn.math.sqrt(16)           # 4.0
now = await db.fn.time.now()               # datetime
length = await db.fn.string.len("hello")   # 5
sha = await db.fn.crypto.sha256("data")    # hash string

# Custom user-defined functions (fn::function)
result = await db.fn.my_custom_function(arg1, arg2)
# Executes: RETURN fn::my_custom_function($arg0, $arg1)
```

**Available Namespaces:**
`array`, `crypto`, `duration`, `geo`, `http`, `math`, `meta`, `object`, `parse`, `rand`, `session`, `string`, `time`, `type`, `vector`

### Live Queries

Real-time updates via WebSocket:

```python
from surreal_sdk import LiveAction

# Async iterator pattern (recommended)
async with db.live_select(
    "orders",
    where="status = $status",
    params={"status": "pending"},
    auto_resubscribe=True,  # Auto-reconnect on WebSocket drop
) as stream:
    async for change in stream:
        match change.action:
            case LiveAction.CREATE:
                print(f"New order: {change.result}")
            case LiveAction.UPDATE:
                print(f"Updated: {change.record_id}")
            case LiveAction.DELETE:
                print(f"Deleted: {change.record_id}")

# Callback-based pattern
from surreal_sdk import LiveQuery, LiveNotification

async def on_change(notification: LiveNotification):
    print(f"{notification.action}: {notification.result}")

live = LiveQuery(ws_conn, "orders")
await live.subscribe(on_change)
# ... record changes trigger callbacks ...
await live.unsubscribe()
```

**Typed Function Calls:**

```python
from pydantic import BaseModel

class VoteResult(BaseModel):
    success: bool
    count: int

# Call SurrealDB function with typed return
result = await db.call(
    "cast_vote",
    params={"user": "alice", "vote": "yes"},
    return_type=VoteResult
)
print(result.success, result.count)  # Typed access
```

---

## ORM Features

### Live Models (Real-time at ORM Level)

```python
from surreal_orm import LiveAction

# Subscribe to model changes with full Pydantic instances
async with User.objects().filter(role="admin").live() as stream:
    async for event in stream:
        print(event.action, event.instance.name, event.record_id)

# Change Feed (HTTP, no WebSocket needed)
async for event in Order.objects().changes(since="2026-01-01"):
    print(event.action, event.instance.total)
```

### QuerySet with Django-style Lookups

```python
# Filter with lookups
users = await User.objects().filter(age__gte=18, name__startswith="A").exec()

# Supported lookups
# exact, gt, gte, lt, lte, in, not_in, like, ilike,
# contains, icontains, not_contains, containsall, containsany,
# startswith, istartswith, endswith, iendswith, match, regex, isnull

# Q objects for complex OR/AND/NOT queries
from surreal_orm import Q
users = await User.objects().filter(
    Q(name__contains="alice") | Q(email__contains="alice"),
    role="admin",
).order_by("-created_at").limit(10).exec()
```

### ORM Transactions

```python
from surreal_orm import SurrealDBConnectionManager

# Via ConnectionManager
async with SurrealDBConnectionManager.transaction() as tx:
    user = User(name="Alice", balance=1000)
    await user.save(tx=tx)

    order = Order(user_id=user.id, total=100)
    await order.save(tx=tx)
    # Auto-commit on success, auto-rollback on exception

# Via Model class method
async with User.transaction() as tx:
    await user1.save(tx=tx)
    await user2.delete(tx=tx)
```

### Aggregations

```python
# Simple aggregations
total = await User.objects().count()
total = await User.objects().filter(active=True).count()

# Field aggregations
avg_age = await User.objects().avg("age")
total = await Order.objects().filter(status="paid").sum("amount")
min_val = await Product.objects().min("price")
max_val = await Product.objects().max("price")
```

### GROUP BY with Aggregations

```python
from surreal_orm import Count, Sum, Avg

# Group by single field
stats = await Order.objects().values("status").annotate(
    count=Count(),
    total=Sum("amount"),
).exec()
# Result: [{"status": "paid", "count": 42, "total": 5000}, ...]

# Group by multiple fields
monthly = await Order.objects().values("status", "month").annotate(
    count=Count(),
).exec()
```

### Bulk Operations

```python
# Bulk create
users = [User(name=f"User{i}") for i in range(100)]
created = await User.objects().bulk_create(users)

# Atomic bulk create (all-or-nothing)
created = await User.objects().bulk_create(users, atomic=True)

# Bulk update
updated = await User.objects().filter(status="pending").bulk_update(
    {"status": "active"}
)

# Bulk delete
deleted = await User.objects().filter(status="deleted").bulk_delete()
```

### Table Types

| Type     | Description                 |
| -------- | --------------------------- |
| `NORMAL` | Standard table (default)    |
| `USER`   | Auth table with JWT support |
| `STREAM` | Real-time with CHANGEFEED   |
| `HASH`   | Lookup/cache (SCHEMALESS)   |

```python
from surreal_orm import BaseSurrealModel, SurrealConfigDict
from surreal_orm.types import TableType

class User(BaseSurrealModel):
    model_config = SurrealConfigDict(
        table_type=TableType.USER,
        permissions={"select": "$auth.id = id"},
    )
```

### JWT Authentication

```python
from surreal_orm.auth import AuthenticatedUserMixin
from surreal_orm.fields import Encrypted

class User(AuthenticatedUserMixin, BaseSurrealModel):
    model_config = SurrealConfigDict(table_type=TableType.USER)
    email: str
    password: Encrypted  # Auto-hashed with argon2
    name: str

# Signup
user = await User.signup(email="alice@example.com", password="secret", name="Alice")

# Signin
user, token = await User.signin(email="alice@example.com", password="secret")

# Validate token
user = await User.authenticate_token(token)
```

---

## CLI Commands

Requires `pip install surrealdb-orm[cli]`

| Command             | Description                            |
| ------------------- | -------------------------------------- |
| `makemigrations`    | Generate migration files               |
| `migrate`           | Apply schema migrations                |
| `rollback <target>` | Rollback to migration                  |
| `status`            | Show migration status                  |
| `shell`             | Interactive SurrealQL shell            |
| `inspectdb`         | Generate models from existing database |
| `schemadiff`        | Compare models against live schema     |

```bash
# Generate and apply migrations
surreal-orm makemigrations --name initial
surreal-orm migrate -u http://localhost:8000 -n myns -d mydb

# Environment variables supported
export SURREAL_URL=http://localhost:8000
export SURREAL_NAMESPACE=myns
export SURREAL_DATABASE=mydb
surreal-orm migrate
```

---

## Documentation

| Document                               | Description              |
| -------------------------------------- | ------------------------ |
| [SDK Guide](docs/sdk.md)               | Full SDK documentation   |
| [Migration System](docs/migrations.md) | Django-style migrations  |
| [Authentication](docs/auth.md)         | JWT authentication guide |
| [Roadmap](docs/roadmap.md)             | Future features planning |
| [CHANGELOG](CHANGELOG.md)              | Version history          |

---

## Contributing

```bash
# Clone and install
git clone https://github.com/EulogySnowfall/SurrealDB-ORM.git
cd SurrealDB-ORM
uv sync

# Run tests (SurrealDB container managed automatically)
make test              # Unit tests only
make test-integration  # With integration tests

# Start SurrealDB manually
make db-up             # Test instance (port 8001)
make db-dev            # Dev instance (port 8000)

# Lint
make ci-lint           # Run all linters
```

---

## Related Projects

### [SurrealDB-ORM-lite](https://github.com/EulogySnowfall/SurrealDB-ORM-lite)

A lightweight Django-style ORM built on the **official SurrealDB Python SDK**.

| Feature         | SurrealDB-ORM          | SurrealDB-ORM-lite   |
| --------------- | ---------------------- | -------------------- |
| SDK             | Custom (`surreal_sdk`) | Official `surrealdb` |
| Live Queries    | Full support           | Limited              |
| CBOR Protocol   | Default                | SDK-dependent        |
| Transactions    | Full support           | Basic                |
| Typed Functions | Yes                    | No                   |

Choose **SurrealDB-ORM-lite** if you prefer to use the official SDK with basic ORM features.

```bash
pip install surreal-orm-lite
```

---

## License

MIT License - See [LICENSE](LICENSE) file.

---

**Author:** Yannick Croteau | **GitHub:** [EulogySnowfall](https://github.com/EulogySnowfall)
