parad 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parad-0.1.0/PKG-INFO +90 -0
- parad-0.1.0/README.md +67 -0
- parad-0.1.0/parad/__init__.py +3 -0
- parad-0.1.0/parad/__main__.py +6 -0
- parad-0.1.0/parad/cli.py +34 -0
- parad-0.1.0/parad/commands/__init__.py +0 -0
- parad-0.1.0/parad/commands/config_cmd.py +27 -0
- parad-0.1.0/parad/commands/init.py +48 -0
- parad-0.1.0/parad/commands/query.py +79 -0
- parad-0.1.0/parad/commands/shell.py +40 -0
- parad-0.1.0/parad/commands/status.py +91 -0
- parad-0.1.0/parad/commands/sync.py +98 -0
- parad-0.1.0/parad/config.py +81 -0
- parad-0.1.0/parad/crypto.py +49 -0
- parad-0.1.0/parad/engine.py +143 -0
- parad-0.1.0/parad/gateway.py +127 -0
- parad-0.1.0/parad/types.py +85 -0
- parad-0.1.0/parad.egg-info/PKG-INFO +90 -0
- parad-0.1.0/parad.egg-info/SOURCES.txt +23 -0
- parad-0.1.0/parad.egg-info/dependency_links.txt +1 -0
- parad-0.1.0/parad.egg-info/entry_points.txt +2 -0
- parad-0.1.0/parad.egg-info/requires.txt +4 -0
- parad-0.1.0/parad.egg-info/top_level.txt +1 -0
- parad-0.1.0/pyproject.toml +40 -0
- parad-0.1.0/setup.cfg +4 -0
parad-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parad
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Encrypted local-first SQLite with Telegram cloud sync
|
|
5
|
+
Author-email: nexuss0781 <nexuss0781@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/nexuss0781/Paradox-DB
|
|
7
|
+
Project-URL: Repository, https://github.com/nexuss0781/Paradox-DB
|
|
8
|
+
Project-URL: Issues, https://github.com/nexuss0781/Paradox-DB/issues
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: click>=8.0
|
|
20
|
+
Requires-Dist: httpx>=0.25
|
|
21
|
+
Requires-Dist: cryptography>=41.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
|
|
24
|
+
# parad
|
|
25
|
+
|
|
26
|
+
Encrypted local-first SQLite with Telegram cloud sync.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install parad
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Create an encrypted database
|
|
38
|
+
parad init mydb
|
|
39
|
+
|
|
40
|
+
# Push to cloud
|
|
41
|
+
parad push
|
|
42
|
+
|
|
43
|
+
# Pull latest
|
|
44
|
+
parad pull
|
|
45
|
+
|
|
46
|
+
# Check status
|
|
47
|
+
parad status
|
|
48
|
+
|
|
49
|
+
# Interactive SQL
|
|
50
|
+
parad shell
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| Command | Description |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
58
|
+
| `parad push` | Push database to Telegram cloud |
|
|
59
|
+
| `parad pull [version]` | Pull latest or specific version |
|
|
60
|
+
| `parad sync` | Push then pull |
|
|
61
|
+
| `parad status` | Local vs remote version |
|
|
62
|
+
| `parad versions` | List all remote versions |
|
|
63
|
+
| `parad rollback <ver>` | Rollback to previous version |
|
|
64
|
+
| `parad exec <sql>` | Run raw SQL |
|
|
65
|
+
| `parad insert <table> <json>` | Insert a row |
|
|
66
|
+
| `parad select <table> [where]` | Query rows |
|
|
67
|
+
| `parad update <table> <set> <where>` | Update rows |
|
|
68
|
+
| `parad delete <table> <where>` | Delete rows |
|
|
69
|
+
| `parad shell` | Interactive SQL REPL |
|
|
70
|
+
| `parad config show/set` | Manage config |
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
Config lives at `~/.paradox/config.json`:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"database_path": "~/.paradox/data.db",
|
|
79
|
+
"sync": {
|
|
80
|
+
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
81
|
+
"api_key": "pk_..."
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Security
|
|
87
|
+
|
|
88
|
+
- AES-256-CBC encryption at rest
|
|
89
|
+
- PBKDF2-HMAC-SHA512 key derivation (256k iterations)
|
|
90
|
+
- Your passphrase never leaves your machine
|
parad-0.1.0/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# parad
|
|
2
|
+
|
|
3
|
+
Encrypted local-first SQLite with Telegram cloud sync.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install parad
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Create an encrypted database
|
|
15
|
+
parad init mydb
|
|
16
|
+
|
|
17
|
+
# Push to cloud
|
|
18
|
+
parad push
|
|
19
|
+
|
|
20
|
+
# Pull latest
|
|
21
|
+
parad pull
|
|
22
|
+
|
|
23
|
+
# Check status
|
|
24
|
+
parad status
|
|
25
|
+
|
|
26
|
+
# Interactive SQL
|
|
27
|
+
parad shell
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
35
|
+
| `parad push` | Push database to Telegram cloud |
|
|
36
|
+
| `parad pull [version]` | Pull latest or specific version |
|
|
37
|
+
| `parad sync` | Push then pull |
|
|
38
|
+
| `parad status` | Local vs remote version |
|
|
39
|
+
| `parad versions` | List all remote versions |
|
|
40
|
+
| `parad rollback <ver>` | Rollback to previous version |
|
|
41
|
+
| `parad exec <sql>` | Run raw SQL |
|
|
42
|
+
| `parad insert <table> <json>` | Insert a row |
|
|
43
|
+
| `parad select <table> [where]` | Query rows |
|
|
44
|
+
| `parad update <table> <set> <where>` | Update rows |
|
|
45
|
+
| `parad delete <table> <where>` | Delete rows |
|
|
46
|
+
| `parad shell` | Interactive SQL REPL |
|
|
47
|
+
| `parad config show/set` | Manage config |
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Config lives at `~/.paradox/config.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"database_path": "~/.paradox/data.db",
|
|
56
|
+
"sync": {
|
|
57
|
+
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
58
|
+
"api_key": "pk_..."
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Security
|
|
64
|
+
|
|
65
|
+
- AES-256-CBC encryption at rest
|
|
66
|
+
- PBKDF2-HMAC-SHA512 key derivation (256k iterations)
|
|
67
|
+
- Your passphrase never leaves your machine
|
parad-0.1.0/parad/cli.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""parad CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
@click.group()
|
|
7
|
+
@click.version_option(package_name="parad")
|
|
8
|
+
def main():
|
|
9
|
+
"""parad — Encrypted local-first SQLite with Telegram cloud sync."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Import and register all commands
|
|
14
|
+
from parad.commands.init import init
|
|
15
|
+
from parad.commands.sync import push, pull, sync
|
|
16
|
+
from parad.commands.status import status, versions, rollback
|
|
17
|
+
from parad.commands.query import exec_cmd, insert, select, update, delete
|
|
18
|
+
from parad.commands.shell import shell
|
|
19
|
+
from parad.commands.config_cmd import config_group
|
|
20
|
+
|
|
21
|
+
main.add_command(init)
|
|
22
|
+
main.add_command(push)
|
|
23
|
+
main.add_command(pull)
|
|
24
|
+
main.add_command(sync)
|
|
25
|
+
main.add_command(status)
|
|
26
|
+
main.add_command(versions)
|
|
27
|
+
main.add_command(rollback)
|
|
28
|
+
main.add_command(exec_cmd, name="exec")
|
|
29
|
+
main.add_command(insert)
|
|
30
|
+
main.add_command(select)
|
|
31
|
+
main.add_command(update)
|
|
32
|
+
main.add_command(delete)
|
|
33
|
+
main.add_command(shell)
|
|
34
|
+
main.add_command(config_group)
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""parad config — View and update configuration."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from parad.config import load_config, set_config_value, save_config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group("config")
|
|
8
|
+
def config_group():
|
|
9
|
+
"""Manage parad configuration."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@config_group.command("show")
|
|
14
|
+
def config_show():
|
|
15
|
+
"""Show current configuration."""
|
|
16
|
+
import json
|
|
17
|
+
config = load_config()
|
|
18
|
+
click.echo(json.dumps(config.model_dump(), indent=2))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@config_group.command("set")
|
|
22
|
+
@click.argument("key")
|
|
23
|
+
@click.argument("value")
|
|
24
|
+
def config_set(key: str, value: str):
|
|
25
|
+
"""Set a config value: parad config set sync.api_key pk_xxx"""
|
|
26
|
+
set_config_value(key, value)
|
|
27
|
+
click.echo(f"✓ Set {key} = {value}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""parad init — Create a new encrypted database and register with gateway."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from parad.config import load_config, save_config, CONFIG_DIR
|
|
6
|
+
from parad.engine import Engine
|
|
7
|
+
from parad.gateway import GatewayClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.argument("name")
|
|
12
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
13
|
+
@click.option("--gateway", envvar="PARADOX_GATEWAY_URL", default=None)
|
|
14
|
+
def init(name: str, passphrase: str, gateway: str | None):
|
|
15
|
+
"""Create a new encrypted database and register with gateway."""
|
|
16
|
+
config = load_config()
|
|
17
|
+
|
|
18
|
+
if gateway:
|
|
19
|
+
config.sync.gateway_url = gateway
|
|
20
|
+
|
|
21
|
+
db_path = CONFIG_DIR / f"{name}.db"
|
|
22
|
+
config.database_path = str(db_path)
|
|
23
|
+
|
|
24
|
+
# Create local encrypted database
|
|
25
|
+
engine = Engine(str(db_path), passphrase)
|
|
26
|
+
engine.open()
|
|
27
|
+
engine.create_tables()
|
|
28
|
+
engine.close()
|
|
29
|
+
|
|
30
|
+
click.echo(f"✓ Created encrypted database: {db_path}")
|
|
31
|
+
|
|
32
|
+
# Auto-register if no API key
|
|
33
|
+
if not config.sync.api_key:
|
|
34
|
+
gw = GatewayClient(config.sync.gateway_url)
|
|
35
|
+
try:
|
|
36
|
+
result = gw.register("0", config.sync.gateway_url)
|
|
37
|
+
config.sync.api_key = result.api_key
|
|
38
|
+
save_config(config)
|
|
39
|
+
click.echo(f"✓ Registered with gateway (user: {result.user_id})")
|
|
40
|
+
click.echo(f" API key: {result.api_key}")
|
|
41
|
+
except Exception as e:
|
|
42
|
+
click.echo(f"⚠ Gateway registration failed: {e}")
|
|
43
|
+
click.echo(" You can register later with: parad config set sync.api_key <key>")
|
|
44
|
+
else:
|
|
45
|
+
click.echo(" Gateway already configured")
|
|
46
|
+
|
|
47
|
+
save_config(config)
|
|
48
|
+
click.echo(f"\nDatabase ready at: {db_path}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""parad exec/insert/select/update/delete — Local SQL operations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
from parad.config import load_config
|
|
6
|
+
from parad.engine import Engine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command("exec")
|
|
10
|
+
@click.argument("sql")
|
|
11
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
12
|
+
def exec_cmd(sql: str, passphrase: str):
|
|
13
|
+
"""Execute raw SQL on the local database."""
|
|
14
|
+
config = load_config()
|
|
15
|
+
with Engine(config.database_path, passphrase) as engine:
|
|
16
|
+
rows = engine.execute(sql)
|
|
17
|
+
if rows:
|
|
18
|
+
for row in rows:
|
|
19
|
+
click.echo(json.dumps(dict(row), default=str))
|
|
20
|
+
else:
|
|
21
|
+
click.echo("OK")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command("insert")
|
|
25
|
+
@click.argument("table")
|
|
26
|
+
@click.argument("data_json")
|
|
27
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
28
|
+
def insert(table: str, data_json: str, passphrase: str):
|
|
29
|
+
"""Insert a row: parad insert <table> '{"col": "val"}'."""
|
|
30
|
+
config = load_config()
|
|
31
|
+
data = json.loads(data_json)
|
|
32
|
+
with Engine(config.database_path, passphrase) as engine:
|
|
33
|
+
rowid = engine.insert(table, data)
|
|
34
|
+
click.echo(f"Inserted rowid={rowid}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.command("select")
|
|
38
|
+
@click.argument("table")
|
|
39
|
+
@click.argument("where_json", required=False)
|
|
40
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
41
|
+
def select(table: str, where_json: str | None, passphrase: str):
|
|
42
|
+
"""Query rows: parad select <table> ['{"col": "val"}']."""
|
|
43
|
+
config = load_config()
|
|
44
|
+
where = json.loads(where_json) if where_json else None
|
|
45
|
+
with Engine(config.database_path, passphrase) as engine:
|
|
46
|
+
rows = engine.select(table, where)
|
|
47
|
+
if not rows:
|
|
48
|
+
click.echo("No rows found.")
|
|
49
|
+
return
|
|
50
|
+
for row in rows:
|
|
51
|
+
click.echo(json.dumps(dict(row), default=str))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@click.command("update")
|
|
55
|
+
@click.argument("table")
|
|
56
|
+
@click.argument("set_json")
|
|
57
|
+
@click.argument("where_json")
|
|
58
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
59
|
+
def update(table: str, set_json: str, where_json: str, passphrase: str):
|
|
60
|
+
"""Update rows: parad update <table> '{"col":"val"}' '{"id":1}'."""
|
|
61
|
+
config = load_config()
|
|
62
|
+
set_data = json.loads(set_json)
|
|
63
|
+
where = json.loads(where_json)
|
|
64
|
+
with Engine(config.database_path, passphrase) as engine:
|
|
65
|
+
changes = engine.update(table, set_data, where)
|
|
66
|
+
click.echo(f"Updated {changes} row(s)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@click.command("delete")
|
|
70
|
+
@click.argument("table")
|
|
71
|
+
@click.argument("where_json")
|
|
72
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
73
|
+
def delete(table: str, where_json: str, passphrase: str):
|
|
74
|
+
"""Delete rows: parad delete <table> '{"id":1}'."""
|
|
75
|
+
config = load_config()
|
|
76
|
+
where = json.loads(where_json)
|
|
77
|
+
with Engine(config.database_path, passphrase) as engine:
|
|
78
|
+
changes = engine.delete(table, where)
|
|
79
|
+
click.echo(f"Deleted {changes} row(s)")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""parad shell — Interactive SQL REPL."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from parad.config import load_config
|
|
5
|
+
from parad.engine import Engine
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command("shell")
|
|
9
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
10
|
+
def shell(passphrase: str):
|
|
11
|
+
"""Interactive SQL shell for the local database."""
|
|
12
|
+
config = load_config()
|
|
13
|
+
|
|
14
|
+
click.echo(f"parad shell — {config.database_path}")
|
|
15
|
+
click.echo("Type SQL commands, or 'quit' to exit.\n")
|
|
16
|
+
|
|
17
|
+
with Engine(config.database_path, passphrase) as engine:
|
|
18
|
+
while True:
|
|
19
|
+
try:
|
|
20
|
+
line = input("parad> ").strip()
|
|
21
|
+
except (EOFError, KeyboardInterrupt):
|
|
22
|
+
click.echo("\nBye.")
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
if not line:
|
|
26
|
+
continue
|
|
27
|
+
if line.lower() in ("quit", "exit", "\\q"):
|
|
28
|
+
click.echo("Bye.")
|
|
29
|
+
break
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
rows = engine.execute(line)
|
|
33
|
+
if rows:
|
|
34
|
+
import json
|
|
35
|
+
for row in rows:
|
|
36
|
+
click.echo(json.dumps(dict(row), default=str))
|
|
37
|
+
else:
|
|
38
|
+
click.echo("OK")
|
|
39
|
+
except Exception as e:
|
|
40
|
+
click.echo(f"Error: {e}")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""parad status/versions/rollback — View sync status and manage versions."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from parad.config import load_config
|
|
5
|
+
from parad.gateway import GatewayClient, GatewayError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command("status")
|
|
9
|
+
def status():
|
|
10
|
+
"""Show local vs remote sync status."""
|
|
11
|
+
config = load_config()
|
|
12
|
+
gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
result = gw.status()
|
|
16
|
+
except GatewayError as e:
|
|
17
|
+
click.echo(f"✗ Status check failed: {e}")
|
|
18
|
+
raise SystemExit(1)
|
|
19
|
+
|
|
20
|
+
click.echo(f"User: {result.user_id}\n")
|
|
21
|
+
if not result.databases:
|
|
22
|
+
click.echo("No databases found.")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
for db in result.databases:
|
|
26
|
+
click.echo(f" {db.name}")
|
|
27
|
+
click.echo(f" Version: {db.latest_version}")
|
|
28
|
+
click.echo(f" Message ID: {db.latest_message_id}")
|
|
29
|
+
if db.file_hash:
|
|
30
|
+
click.echo(f" Hash: {db.file_hash[:16]}...")
|
|
31
|
+
if db.uploaded_at:
|
|
32
|
+
click.echo(f" Uploaded: {db.uploaded_at}")
|
|
33
|
+
click.echo()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.command("versions")
|
|
37
|
+
@click.option("--name", "-n", default=None, help="Database name")
|
|
38
|
+
def versions(name: str | None):
|
|
39
|
+
"""List all remote versions."""
|
|
40
|
+
config = load_config()
|
|
41
|
+
if not name:
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
name = Path(config.database_path).name
|
|
44
|
+
|
|
45
|
+
gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
result = gw.versions(name)
|
|
49
|
+
except GatewayError as e:
|
|
50
|
+
click.echo(f"✗ Versions query failed: {e}")
|
|
51
|
+
raise SystemExit(1)
|
|
52
|
+
|
|
53
|
+
if not result.versions:
|
|
54
|
+
click.echo(f"No versions found for {name}.")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
click.echo(f"Versions for {name}:\n")
|
|
58
|
+
for v in result.versions:
|
|
59
|
+
size_str = f" ({v.file_size} bytes)" if v.file_size else ""
|
|
60
|
+
type_str = f" [{v.version_type}]" if v.version_type else ""
|
|
61
|
+
click.echo(f" v{v.version}{type_str}{size_str} msg={v.message_id}")
|
|
62
|
+
if v.uploaded_at:
|
|
63
|
+
click.echo(f" {v.uploaded_at}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@click.command("rollback")
|
|
67
|
+
@click.argument("version", type=int)
|
|
68
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
69
|
+
def rollback(version: int, passphrase: str):
|
|
70
|
+
"""Rollback to a previous version."""
|
|
71
|
+
config = load_config()
|
|
72
|
+
from pathlib import Path
|
|
73
|
+
db_name = Path(config.database_path).name
|
|
74
|
+
db_path = Path(config.database_path).expanduser()
|
|
75
|
+
|
|
76
|
+
gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
result = gw.rollback(db_name, version)
|
|
80
|
+
click.echo(f"✓ Rolled back to v{result.rolled_back_to} (new msg={result.new_message_id})")
|
|
81
|
+
except GatewayError as e:
|
|
82
|
+
click.echo(f"✗ Rollback failed: {e}")
|
|
83
|
+
raise SystemExit(1)
|
|
84
|
+
|
|
85
|
+
# Pull the rolled-back version
|
|
86
|
+
try:
|
|
87
|
+
file_bytes = gw.download(db_name)
|
|
88
|
+
db_path.write_bytes(file_bytes)
|
|
89
|
+
click.echo(f"✓ Pulled rolled-back version")
|
|
90
|
+
except GatewayError as e:
|
|
91
|
+
click.echo(f"⚠ Pull after rollback failed: {e}")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""parad push/pull/sync — Upload and download databases."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import click
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from parad.config import load_config, CONFIG_DIR
|
|
7
|
+
from parad.engine import Engine
|
|
8
|
+
from parad.gateway import GatewayClient, GatewayError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_db_name(config) -> str:
|
|
12
|
+
return Path(config.database_path).name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("push")
|
|
16
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
17
|
+
def push(passphrase: str):
|
|
18
|
+
"""Push the local database to the gateway."""
|
|
19
|
+
config = load_config()
|
|
20
|
+
db_path = Path(config.database_path).expanduser()
|
|
21
|
+
|
|
22
|
+
if not db_path.exists():
|
|
23
|
+
click.echo(f"✗ Database not found: {db_path}")
|
|
24
|
+
raise SystemExit(1)
|
|
25
|
+
|
|
26
|
+
engine = Engine(str(db_path), passphrase)
|
|
27
|
+
engine.open()
|
|
28
|
+
raw = engine.get_raw_bytes()
|
|
29
|
+
engine.close()
|
|
30
|
+
|
|
31
|
+
gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
|
|
32
|
+
db_name = _get_db_name(config)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
result = gw.upload(db_name, raw)
|
|
36
|
+
click.echo(f"✓ Pushed {db_name} v{result.version} (msg={result.message_id})")
|
|
37
|
+
except GatewayError as e:
|
|
38
|
+
click.echo(f"✗ Push failed: {e}")
|
|
39
|
+
raise SystemExit(1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.command("pull")
|
|
43
|
+
@click.argument("version", required=False, type=int)
|
|
44
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
45
|
+
def pull(version: int | None, passphrase: str):
|
|
46
|
+
"""Pull database from gateway. Optionally specify a version."""
|
|
47
|
+
config = load_config()
|
|
48
|
+
db_path = Path(config.database_path).expanduser()
|
|
49
|
+
db_name = _get_db_name(config)
|
|
50
|
+
|
|
51
|
+
gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
file_bytes = gw.download(db_name, version)
|
|
55
|
+
except GatewayError as e:
|
|
56
|
+
click.echo(f"✗ Pull failed: {e}")
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
|
|
59
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
db_path.write_bytes(file_bytes)
|
|
61
|
+
|
|
62
|
+
ver_str = f"v{version}" if version else "latest"
|
|
63
|
+
click.echo(f"✓ Pulled {db_name} {ver_str} ({len(file_bytes)} bytes)")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@click.command("sync")
|
|
67
|
+
@click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
|
|
68
|
+
def sync(passphrase: str):
|
|
69
|
+
"""Push local changes, then pull latest from gateway."""
|
|
70
|
+
config = load_config()
|
|
71
|
+
db_path = Path(config.database_path).expanduser()
|
|
72
|
+
db_name = _get_db_name(config)
|
|
73
|
+
|
|
74
|
+
if not db_path.exists():
|
|
75
|
+
click.echo(f"✗ Database not found: {db_path}")
|
|
76
|
+
raise SystemExit(1)
|
|
77
|
+
|
|
78
|
+
# Push
|
|
79
|
+
engine = Engine(str(db_path), passphrase)
|
|
80
|
+
engine.open()
|
|
81
|
+
raw = engine.get_raw_bytes()
|
|
82
|
+
engine.close()
|
|
83
|
+
|
|
84
|
+
gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
result = gw.upload(db_name, raw)
|
|
88
|
+
click.echo(f"✓ Pushed v{result.version}")
|
|
89
|
+
except GatewayError as e:
|
|
90
|
+
click.echo(f"⚠ Push failed: {e}")
|
|
91
|
+
|
|
92
|
+
# Pull
|
|
93
|
+
try:
|
|
94
|
+
file_bytes = gw.download(db_name)
|
|
95
|
+
db_path.write_bytes(file_bytes)
|
|
96
|
+
click.echo(f"✓ Pulled latest ({len(file_bytes)} bytes)")
|
|
97
|
+
except GatewayError as e:
|
|
98
|
+
click.echo(f"⚠ Pull failed: {e}")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""parad configuration management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from parad.types import Config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".paradox"
|
|
9
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG = {
|
|
12
|
+
"database_path": "~/.paradox/data.db",
|
|
13
|
+
"encryption": {
|
|
14
|
+
"cipher": "aes-256-cbc",
|
|
15
|
+
"kdf_iterations": 256000,
|
|
16
|
+
"page_size": 4096,
|
|
17
|
+
},
|
|
18
|
+
"sync": {
|
|
19
|
+
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
20
|
+
"api_key": "",
|
|
21
|
+
"trigger_timer_seconds": 30,
|
|
22
|
+
"trigger_ops_threshold": 50,
|
|
23
|
+
"max_file_size_mb": 50,
|
|
24
|
+
"auto_sync_on_shutdown": True,
|
|
25
|
+
},
|
|
26
|
+
"conflict": {
|
|
27
|
+
"strategy": "last-write-wins",
|
|
28
|
+
"log_conflicts": True,
|
|
29
|
+
},
|
|
30
|
+
"logging": {
|
|
31
|
+
"level": "info",
|
|
32
|
+
"path": "~/.paradox/logs",
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
38
|
+
"""Deep merge override into base."""
|
|
39
|
+
result = base.copy()
|
|
40
|
+
for k, v in override.items():
|
|
41
|
+
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
|
42
|
+
result[k] = _deep_merge(result[k], v)
|
|
43
|
+
else:
|
|
44
|
+
result[k] = v
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config() -> Config:
|
|
49
|
+
"""Load config from ~/.paradox/config.json, merged with defaults."""
|
|
50
|
+
user_config = {}
|
|
51
|
+
if CONFIG_FILE.exists():
|
|
52
|
+
try:
|
|
53
|
+
user_config = json.loads(CONFIG_FILE.read_text())
|
|
54
|
+
except (json.JSONDecodeError, OSError):
|
|
55
|
+
pass
|
|
56
|
+
merged = _deep_merge(DEFAULT_CONFIG, user_config)
|
|
57
|
+
return Config(**merged)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_config(config: Config):
|
|
61
|
+
"""Save config to ~/.paradox/config.json."""
|
|
62
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
CONFIG_FILE.write_text(json.dumps(config.model_dump(), indent=2))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_config_value(key: str, value: str):
|
|
67
|
+
"""Set a single config value using dot notation (e.g. sync.api_key)."""
|
|
68
|
+
config = load_config()
|
|
69
|
+
keys = key.split(".")
|
|
70
|
+
d = config.model_dump()
|
|
71
|
+
current = d
|
|
72
|
+
for k in keys[:-1]:
|
|
73
|
+
current = current[k]
|
|
74
|
+
# Try to parse as appropriate type
|
|
75
|
+
if value.lower() in ("true", "false"):
|
|
76
|
+
current[keys[-1]] = value.lower() == "true"
|
|
77
|
+
elif value.isdigit():
|
|
78
|
+
current[keys[-1]] = int(value)
|
|
79
|
+
else:
|
|
80
|
+
current[keys[-1]] = value
|
|
81
|
+
save_config(Config(**d))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""AES-256-CBC file encryption for parad local database."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import hashlib
|
|
5
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
6
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
7
|
+
from cryptography.hazmat.primitives import hashes
|
|
8
|
+
|
|
9
|
+
SALT = b"paradox-salt"
|
|
10
|
+
KDF_ITERATIONS = 256_000
|
|
11
|
+
KEY_LENGTH = 32 # 256 bits
|
|
12
|
+
IV_LENGTH = 16 # 128 bits
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def derive_key(passphrase: str) -> bytes:
|
|
16
|
+
"""Derive a 256-bit key from passphrase using PBKDF2-HMAC-SHA512."""
|
|
17
|
+
kdf = PBKDF2HMAC(
|
|
18
|
+
algorithm=hashes.SHA512(),
|
|
19
|
+
length=KEY_LENGTH,
|
|
20
|
+
salt=SALT,
|
|
21
|
+
iterations=KDF_ITERATIONS,
|
|
22
|
+
)
|
|
23
|
+
return kdf.derive(passphrase.encode("utf-8"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def encrypt_file(data: bytes, passphrase: str) -> bytes:
|
|
27
|
+
"""Encrypt bytes with AES-256-CBC. Returns IV + ciphertext."""
|
|
28
|
+
key = derive_key(passphrase)
|
|
29
|
+
iv = os.urandom(IV_LENGTH)
|
|
30
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
31
|
+
encryptor = cipher.encryptor()
|
|
32
|
+
# PKCS7 padding
|
|
33
|
+
pad_len = 16 - (len(data) % 16)
|
|
34
|
+
padded = data + bytes([pad_len] * pad_len)
|
|
35
|
+
ciphertext = encryptor.update(padded) + encryptor.finalize()
|
|
36
|
+
return iv + ciphertext
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decrypt_file(data: bytes, passphrase: str) -> bytes:
|
|
40
|
+
"""Decrypt AES-256-CBC data (IV + ciphertext). Returns original bytes."""
|
|
41
|
+
key = derive_key(passphrase)
|
|
42
|
+
iv = data[:IV_LENGTH]
|
|
43
|
+
ciphertext = data[IV_LENGTH:]
|
|
44
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
45
|
+
decryptor = cipher.decryptor()
|
|
46
|
+
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
47
|
+
# Remove PKCS7 padding
|
|
48
|
+
pad_len = padded[-1]
|
|
49
|
+
return padded[:-pad_len]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Local encrypted SQLite engine for parad."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from parad.crypto import encrypt_file, decrypt_file
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Engine:
|
|
11
|
+
"""Encrypted SQLite database engine.
|
|
12
|
+
|
|
13
|
+
Decrypts the file to a temp location, operates on it,
|
|
14
|
+
then re-encrypts and writes back.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, db_path: str, passphrase: str):
|
|
18
|
+
self.db_path = Path(db_path).expanduser()
|
|
19
|
+
self.passphrase = passphrase
|
|
20
|
+
self._conn: sqlite3.Connection | None = None
|
|
21
|
+
self._tmp_path: str | None = None
|
|
22
|
+
|
|
23
|
+
def _decrypt_to_temp(self) -> str:
|
|
24
|
+
"""Decrypt DB to a temp file, return path."""
|
|
25
|
+
if not self.db_path.exists():
|
|
26
|
+
raise FileNotFoundError(f"Database not found: {self.db_path}")
|
|
27
|
+
encrypted = self.db_path.read_bytes()
|
|
28
|
+
decrypted = decrypt_file(encrypted, self.passphrase)
|
|
29
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
30
|
+
tmp.write(decrypted)
|
|
31
|
+
tmp.close()
|
|
32
|
+
self._tmp_path = tmp.name
|
|
33
|
+
return self._tmp_path
|
|
34
|
+
|
|
35
|
+
def _encrypt_from_temp(self):
|
|
36
|
+
"""Encrypt temp file back to db_path."""
|
|
37
|
+
if not self._tmp_path:
|
|
38
|
+
return
|
|
39
|
+
decrypted = Path(self._tmp_path).read_bytes()
|
|
40
|
+
encrypted = encrypt_file(decrypted, self.passphrase)
|
|
41
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
self.db_path.write_bytes(encrypted)
|
|
43
|
+
|
|
44
|
+
def open(self) -> sqlite3.Connection:
|
|
45
|
+
"""Open the encrypted database."""
|
|
46
|
+
tmp_path = self._decrypt_to_temp()
|
|
47
|
+
self._conn = sqlite3.connect(tmp_path)
|
|
48
|
+
self._conn.row_factory = sqlite3.Row
|
|
49
|
+
return self._conn
|
|
50
|
+
|
|
51
|
+
def close(self):
|
|
52
|
+
"""Close and re-encrypt."""
|
|
53
|
+
if self._conn:
|
|
54
|
+
self._conn.close()
|
|
55
|
+
self._conn = None
|
|
56
|
+
self._encrypt_from_temp()
|
|
57
|
+
if self._tmp_path:
|
|
58
|
+
os.unlink(self._tmp_path)
|
|
59
|
+
self._tmp_path = None
|
|
60
|
+
|
|
61
|
+
def __enter__(self):
|
|
62
|
+
self.open()
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *args):
|
|
66
|
+
self.close()
|
|
67
|
+
|
|
68
|
+
def execute(self, sql: str, params: tuple = ()) -> list[dict]:
|
|
69
|
+
"""Execute SQL and return rows as dicts."""
|
|
70
|
+
if not self._conn:
|
|
71
|
+
raise RuntimeError("Database not open")
|
|
72
|
+
cursor = self._conn.execute(sql, params)
|
|
73
|
+
if cursor.description:
|
|
74
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
75
|
+
self._conn.commit()
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
def insert(self, table: str, data: dict) -> int:
|
|
79
|
+
"""Insert a row, return rowid."""
|
|
80
|
+
cols = ", ".join(data.keys())
|
|
81
|
+
placeholders = ", ".join(["?"] * len(data))
|
|
82
|
+
sql = f"INSERT INTO {table} ({cols}) VALUES ({placeholders})"
|
|
83
|
+
cursor = self._conn.execute(sql, tuple(data.values()))
|
|
84
|
+
self._conn.commit()
|
|
85
|
+
return cursor.lastrowid
|
|
86
|
+
|
|
87
|
+
def update(self, table: str, set_data: dict, where: dict) -> int:
|
|
88
|
+
"""Update rows, return changes count."""
|
|
89
|
+
set_clause = ", ".join(f"{k} = ?" for k in set_data)
|
|
90
|
+
where_clause = " AND ".join(f"{k} = ?" for k in where)
|
|
91
|
+
sql = f"UPDATE {table} SET {set_clause} WHERE {where_clause}"
|
|
92
|
+
params = tuple(set_data.values()) + tuple(where.values())
|
|
93
|
+
cursor = self._conn.execute(sql, params)
|
|
94
|
+
self._conn.commit()
|
|
95
|
+
return cursor.rowcount
|
|
96
|
+
|
|
97
|
+
def delete(self, table: str, where: dict) -> int:
|
|
98
|
+
"""Delete rows, return changes count."""
|
|
99
|
+
where_clause = " AND ".join(f"{k} = ?" for k in where)
|
|
100
|
+
sql = f"DELETE FROM {table} WHERE {where_clause}"
|
|
101
|
+
cursor = self._conn.execute(sql, tuple(where.values()))
|
|
102
|
+
self._conn.commit()
|
|
103
|
+
return cursor.rowcount
|
|
104
|
+
|
|
105
|
+
def select(self, table: str, where: dict | None = None) -> list[dict]:
|
|
106
|
+
"""Query rows."""
|
|
107
|
+
sql = f"SELECT * FROM {table}"
|
|
108
|
+
params = ()
|
|
109
|
+
if where:
|
|
110
|
+
where_clause = " AND ".join(f"{k} = ?" for k in where)
|
|
111
|
+
sql += f" WHERE {where_clause}"
|
|
112
|
+
params = tuple(where.values())
|
|
113
|
+
return self.execute(sql, params)
|
|
114
|
+
|
|
115
|
+
def get_raw_bytes(self) -> bytes:
|
|
116
|
+
"""Get the encrypted database as raw bytes (for push)."""
|
|
117
|
+
if self._tmp_path:
|
|
118
|
+
return Path(self._tmp_path).read_bytes()
|
|
119
|
+
if self.db_path.exists():
|
|
120
|
+
encrypted = self.db_path.read_bytes()
|
|
121
|
+
return decrypt_file(encrypted, self.passphrase)
|
|
122
|
+
raise FileNotFoundError(f"Database not found: {self.db_path}")
|
|
123
|
+
|
|
124
|
+
def create_tables(self):
|
|
125
|
+
"""Create default tables for a new database."""
|
|
126
|
+
self._conn.execute("""
|
|
127
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
128
|
+
key TEXT PRIMARY KEY,
|
|
129
|
+
value TEXT
|
|
130
|
+
)
|
|
131
|
+
""")
|
|
132
|
+
self._conn.commit()
|
|
133
|
+
|
|
134
|
+
def list_tables(self) -> list[str]:
|
|
135
|
+
"""List all user tables."""
|
|
136
|
+
rows = self.execute(
|
|
137
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_%' ORDER BY name"
|
|
138
|
+
)
|
|
139
|
+
return [r["name"] for r in rows]
|
|
140
|
+
|
|
141
|
+
def table_info(self, table: str) -> list[dict]:
|
|
142
|
+
"""Get column info for a table."""
|
|
143
|
+
return self.execute(f"PRAGMA table_info({table})")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""HTTP client for the Paradox-DB Gateway REST API."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from parad.types import (
|
|
6
|
+
RegisterResponse,
|
|
7
|
+
UploadResponse,
|
|
8
|
+
StatusResponse,
|
|
9
|
+
VersionsResponse,
|
|
10
|
+
RollbackResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GatewayError(Exception):
|
|
15
|
+
"""Gateway API error."""
|
|
16
|
+
def __init__(self, status_code: int, detail: str):
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.detail = detail
|
|
19
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GatewayClient:
|
|
23
|
+
"""Client for the Paradox-DB Gateway REST API."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, gateway_url: str, api_key: str = ""):
|
|
26
|
+
self.gateway_url = gateway_url.rstrip("/")
|
|
27
|
+
self.api_key = api_key
|
|
28
|
+
|
|
29
|
+
def _headers(self) -> dict:
|
|
30
|
+
h = {"Content-Type": "application/json"}
|
|
31
|
+
if self.api_key:
|
|
32
|
+
h["X-API-Key"] = self.api_key
|
|
33
|
+
return h
|
|
34
|
+
|
|
35
|
+
def register(self, channel_id: str, bot_token_id: str = "") -> RegisterResponse:
|
|
36
|
+
"""Register a new user with the gateway."""
|
|
37
|
+
import httpx
|
|
38
|
+
resp = httpx.post(
|
|
39
|
+
f"{self.gateway_url}/auth/register",
|
|
40
|
+
json={"channel_id": channel_id, "bot_token_id": bot_token_id},
|
|
41
|
+
headers=self._headers(),
|
|
42
|
+
timeout=30,
|
|
43
|
+
)
|
|
44
|
+
if resp.status_code != 200:
|
|
45
|
+
raise GatewayError(resp.status_code, resp.text)
|
|
46
|
+
data = resp.json()
|
|
47
|
+
self.api_key = data.get("api_key", self.api_key)
|
|
48
|
+
return RegisterResponse(**data)
|
|
49
|
+
|
|
50
|
+
def upload(
|
|
51
|
+
self,
|
|
52
|
+
database_name: str,
|
|
53
|
+
file_bytes: bytes,
|
|
54
|
+
version: int = 0,
|
|
55
|
+
version_type: str = "full",
|
|
56
|
+
) -> UploadResponse:
|
|
57
|
+
"""Upload a database file to the gateway."""
|
|
58
|
+
import httpx
|
|
59
|
+
file_b64 = base64.b64encode(file_bytes).decode()
|
|
60
|
+
resp = httpx.post(
|
|
61
|
+
f"{self.gateway_url}/upload",
|
|
62
|
+
json={
|
|
63
|
+
"database_name": database_name,
|
|
64
|
+
"file_data": file_b64,
|
|
65
|
+
"version_type": version_type,
|
|
66
|
+
"version": version,
|
|
67
|
+
},
|
|
68
|
+
headers=self._headers(),
|
|
69
|
+
timeout=120,
|
|
70
|
+
)
|
|
71
|
+
if resp.status_code != 200:
|
|
72
|
+
raise GatewayError(resp.status_code, resp.text)
|
|
73
|
+
return UploadResponse(**resp.json())
|
|
74
|
+
|
|
75
|
+
def download(self, database_name: str, version: int | None = None) -> bytes:
|
|
76
|
+
"""Download a database file from the gateway."""
|
|
77
|
+
import httpx
|
|
78
|
+
params = {"database_name": database_name}
|
|
79
|
+
if version is not None:
|
|
80
|
+
params["version"] = version
|
|
81
|
+
resp = httpx.get(
|
|
82
|
+
f"{self.gateway_url}/download",
|
|
83
|
+
params=params,
|
|
84
|
+
headers=self._headers(),
|
|
85
|
+
timeout=120,
|
|
86
|
+
)
|
|
87
|
+
if resp.status_code != 200:
|
|
88
|
+
raise GatewayError(resp.status_code, resp.text)
|
|
89
|
+
return resp.content
|
|
90
|
+
|
|
91
|
+
def status(self) -> StatusResponse:
|
|
92
|
+
"""Get sync status from the gateway."""
|
|
93
|
+
import httpx
|
|
94
|
+
resp = httpx.get(
|
|
95
|
+
f"{self.gateway_url}/status",
|
|
96
|
+
headers=self._headers(),
|
|
97
|
+
timeout=30,
|
|
98
|
+
)
|
|
99
|
+
if resp.status_code != 200:
|
|
100
|
+
raise GatewayError(resp.status_code, resp.text)
|
|
101
|
+
return StatusResponse(**resp.json())
|
|
102
|
+
|
|
103
|
+
def versions(self, database_name: str) -> VersionsResponse:
|
|
104
|
+
"""List all versions for a database."""
|
|
105
|
+
import httpx
|
|
106
|
+
resp = httpx.get(
|
|
107
|
+
f"{self.gateway_url}/versions",
|
|
108
|
+
params={"database_name": database_name},
|
|
109
|
+
headers=self._headers(),
|
|
110
|
+
timeout=30,
|
|
111
|
+
)
|
|
112
|
+
if resp.status_code != 200:
|
|
113
|
+
raise GatewayError(resp.status_code, resp.text)
|
|
114
|
+
return VersionsResponse(**resp.json())
|
|
115
|
+
|
|
116
|
+
def rollback(self, database_name: str, target_version: int) -> RollbackResponse:
|
|
117
|
+
"""Rollback a database to a target version."""
|
|
118
|
+
import httpx
|
|
119
|
+
resp = httpx.post(
|
|
120
|
+
f"{self.gateway_url}/rollback",
|
|
121
|
+
json={"database_name": database_name, "target_version": target_version},
|
|
122
|
+
headers=self._headers(),
|
|
123
|
+
timeout=120,
|
|
124
|
+
)
|
|
125
|
+
if resp.status_code != 200:
|
|
126
|
+
raise GatewayError(resp.status_code, resp.text)
|
|
127
|
+
return RollbackResponse(**resp.json())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Pydantic models for parad types."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# ── Config ──────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
class EncryptionConfig(BaseModel):
|
|
10
|
+
cipher: str = "aes-256-cbc"
|
|
11
|
+
kdf_iterations: int = 256000
|
|
12
|
+
page_size: int = 4096
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SyncConfig(BaseModel):
|
|
16
|
+
gateway_url: str = "https://paradox-db.onrender.com/v1"
|
|
17
|
+
api_key: str = ""
|
|
18
|
+
trigger_timer_seconds: int = 30
|
|
19
|
+
trigger_ops_threshold: int = 50
|
|
20
|
+
max_file_size_mb: int = 50
|
|
21
|
+
auto_sync_on_shutdown: bool = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConflictConfig(BaseModel):
|
|
25
|
+
strategy: str = "last-write-wins"
|
|
26
|
+
log_conflicts: bool = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LoggingConfig(BaseModel):
|
|
30
|
+
level: str = "info"
|
|
31
|
+
path: str = "~/.paradox/logs"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Config(BaseModel):
|
|
35
|
+
database_path: str = "~/.paradox/data.db"
|
|
36
|
+
encryption: EncryptionConfig = EncryptionConfig()
|
|
37
|
+
sync: SyncConfig = SyncConfig()
|
|
38
|
+
conflict: ConflictConfig = ConflictConfig()
|
|
39
|
+
logging: LoggingConfig = LoggingConfig()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── API Responses ───────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
class RegisterResponse(BaseModel):
|
|
45
|
+
user_id: str
|
|
46
|
+
api_key: str
|
|
47
|
+
jwt: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UploadResponse(BaseModel):
|
|
51
|
+
request_id: str
|
|
52
|
+
message_id: str
|
|
53
|
+
version: int
|
|
54
|
+
uploaded_at: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DatabaseStatus(BaseModel):
|
|
58
|
+
name: str
|
|
59
|
+
latest_version: int
|
|
60
|
+
latest_message_id: str
|
|
61
|
+
file_hash: str | None = None
|
|
62
|
+
uploaded_at: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class StatusResponse(BaseModel):
|
|
66
|
+
user_id: str
|
|
67
|
+
databases: list[DatabaseStatus] = []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class VersionEntry(BaseModel):
|
|
71
|
+
version: int
|
|
72
|
+
message_id: str
|
|
73
|
+
file_hash: str | None = None
|
|
74
|
+
file_size: int | None = None
|
|
75
|
+
version_type: str | None = None
|
|
76
|
+
uploaded_at: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class VersionsResponse(BaseModel):
|
|
80
|
+
versions: list[VersionEntry] = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RollbackResponse(BaseModel):
|
|
84
|
+
rolled_back_to: int
|
|
85
|
+
new_message_id: str
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parad
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Encrypted local-first SQLite with Telegram cloud sync
|
|
5
|
+
Author-email: nexuss0781 <nexuss0781@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/nexuss0781/Paradox-DB
|
|
7
|
+
Project-URL: Repository, https://github.com/nexuss0781/Paradox-DB
|
|
8
|
+
Project-URL: Issues, https://github.com/nexuss0781/Paradox-DB/issues
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: click>=8.0
|
|
20
|
+
Requires-Dist: httpx>=0.25
|
|
21
|
+
Requires-Dist: cryptography>=41.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
|
|
24
|
+
# parad
|
|
25
|
+
|
|
26
|
+
Encrypted local-first SQLite with Telegram cloud sync.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install parad
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Create an encrypted database
|
|
38
|
+
parad init mydb
|
|
39
|
+
|
|
40
|
+
# Push to cloud
|
|
41
|
+
parad push
|
|
42
|
+
|
|
43
|
+
# Pull latest
|
|
44
|
+
parad pull
|
|
45
|
+
|
|
46
|
+
# Check status
|
|
47
|
+
parad status
|
|
48
|
+
|
|
49
|
+
# Interactive SQL
|
|
50
|
+
parad shell
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| Command | Description |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
58
|
+
| `parad push` | Push database to Telegram cloud |
|
|
59
|
+
| `parad pull [version]` | Pull latest or specific version |
|
|
60
|
+
| `parad sync` | Push then pull |
|
|
61
|
+
| `parad status` | Local vs remote version |
|
|
62
|
+
| `parad versions` | List all remote versions |
|
|
63
|
+
| `parad rollback <ver>` | Rollback to previous version |
|
|
64
|
+
| `parad exec <sql>` | Run raw SQL |
|
|
65
|
+
| `parad insert <table> <json>` | Insert a row |
|
|
66
|
+
| `parad select <table> [where]` | Query rows |
|
|
67
|
+
| `parad update <table> <set> <where>` | Update rows |
|
|
68
|
+
| `parad delete <table> <where>` | Delete rows |
|
|
69
|
+
| `parad shell` | Interactive SQL REPL |
|
|
70
|
+
| `parad config show/set` | Manage config |
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
Config lives at `~/.paradox/config.json`:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"database_path": "~/.paradox/data.db",
|
|
79
|
+
"sync": {
|
|
80
|
+
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
81
|
+
"api_key": "pk_..."
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Security
|
|
87
|
+
|
|
88
|
+
- AES-256-CBC encryption at rest
|
|
89
|
+
- PBKDF2-HMAC-SHA512 key derivation (256k iterations)
|
|
90
|
+
- Your passphrase never leaves your machine
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
parad/__init__.py
|
|
4
|
+
parad/__main__.py
|
|
5
|
+
parad/cli.py
|
|
6
|
+
parad/config.py
|
|
7
|
+
parad/crypto.py
|
|
8
|
+
parad/engine.py
|
|
9
|
+
parad/gateway.py
|
|
10
|
+
parad/types.py
|
|
11
|
+
parad.egg-info/PKG-INFO
|
|
12
|
+
parad.egg-info/SOURCES.txt
|
|
13
|
+
parad.egg-info/dependency_links.txt
|
|
14
|
+
parad.egg-info/entry_points.txt
|
|
15
|
+
parad.egg-info/requires.txt
|
|
16
|
+
parad.egg-info/top_level.txt
|
|
17
|
+
parad/commands/__init__.py
|
|
18
|
+
parad/commands/config_cmd.py
|
|
19
|
+
parad/commands/init.py
|
|
20
|
+
parad/commands/query.py
|
|
21
|
+
parad/commands/shell.py
|
|
22
|
+
parad/commands/status.py
|
|
23
|
+
parad/commands/sync.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
parad
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "parad"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Encrypted local-first SQLite with Telegram cloud sync"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "nexuss0781", email = "nexuss0781@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Database",
|
|
22
|
+
"Topic :: Security :: Cryptography",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"click>=8.0",
|
|
26
|
+
"httpx>=0.25",
|
|
27
|
+
"cryptography>=41.0",
|
|
28
|
+
"pydantic>=2.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/nexuss0781/Paradox-DB"
|
|
33
|
+
Repository = "https://github.com/nexuss0781/Paradox-DB"
|
|
34
|
+
Issues = "https://github.com/nexuss0781/Paradox-DB/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
parad = "parad.cli:main"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
include = ["parad*"]
|
parad-0.1.0/setup.cfg
ADDED