# MonkeyDB

Schemaless DynamoDB for agents. One database per app, one table per entity. Indexes are declared up front and immutable; range queries fall back to your declared sparse indices or the built-in recency index `t`.

## Table lifecycle

```http
POST /v1/:db/:table        # Create table (optionally with declared indices)
GET  /v1/:db                # List tables in a database
GET  /v1/:db/:table         # Table metadata
DELETE /v1/:db/:table       # Soft-delete (recoverable for 7 days)
```

Names: `[a-z0-9_-]{1,64}`. Names starting with `_` are reserved.

### Declaring indices

Indices live in 5 sparse slots (`i1`...`i5`). Each declares a top-level `hashField` and an optional `rangeField`:

```json
{
  "indices": {
    "i1": { "hashField": "status", "rangeField": "createdAt" },
    "i2": { "hashField": "userId" }
  }
}
```

Records that lack an index field are simply omitted from that GSI (sparse), so you can add indexed records later without backfilling existing rows.

## Records

```http
PUT    /v1/:db/:table                       # Upsert
GET    /v1/:db/:table/:hashKey              # Get (hash-only)
GET    /v1/:db/:table/:hashKey/:rangeKey    # Get (composite)
DELETE /v1/:db/:table/:hashKey              # Delete (hash-only)
DELETE /v1/:db/:table/:hashKey/:rangeKey    # Delete (composite)
```

### Upsert

```json
PUT /v1/app/orders
{
  "hashKey": "order_123",
  "rangeKey": "item_a",
  "data": { "status": "active", "createdAt": "2026-04-22T12:00:00Z", "amount": 4200 },
  "ttl": 1735689600
}
```

`hashKey` is required; `rangeKey` is optional (records without one share a single `#` slot per hash). `data` is a free-form JSON object, but values referenced by declared indices must be string / number / strict-ISO date string / Date / boolean — anything else is rejected with `invalid_index_value`. `ttl` (epoch seconds) hooks into native DynamoDB TTL and the record is removed automatically once it elapses.

Records that lack a declared index field simply don't appear in that GSI (sparse) — the put still succeeds.

## Query

```http
POST /v1/:db/:table/query
```

Three forms — pick one:

```json
// 1. By primary key (PK)
{ "hash": "user_123", "range": { "beginsWith": "order_" }, "limit": 50 }

// 2. By recency (the built-in "t" index — every record has it)
{ "index": "t", "range": { "gte": "2026-04-01T00:00:00Z" }, "ascending": false }

// 3. By a declared sparse index (i1...i5)
{ "index": "i1", "hash": "active", "range": { "gte": 1700000000000 }, "limit": 50 }
```

### Range operators

`eq` / `lt` / `lte` / `gt` / `gte` / `between: [low, high]` (both inclusive) / `beginsWith` (strings only). Numeric values can be supplied as numbers or as strict ISO-8601 strings — they get coerced to epoch ms before the query is dispatched.

### Pagination

Pass back the `cursor` returned in a previous response. `cursor: null` means the last page.

```json
{
  "items": [ { "hashKey": "...", "rangeKey": "...", "data": { ... }, "updatedAt": 1700000000000 } ],
  "count": 50,
  "cursor": "opaque-string-or-null"
}
```

## Batch

```http
POST /v1/:db/:table/batch
```

Up to 25 mixed put/delete operations applied atomically — if any one fails validation, none are persisted.

```json
{
  "operations": [
    { "op": "put",    "hashKey": "order_1", "data": { "status": "active" } },
    { "op": "put",    "hashKey": "order_2", "rangeKey": "item_a", "data": { "qty": 3 } },
    { "op": "delete", "hashKey": "order_3" }
  ]
}
```

Response: `{ "count": 3 }`. Same key (hashKey + rangeKey) appearing twice in one batch is rejected with `batch_duplicate_keys` — split it into two requests if you need both ops.
