granoladb 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.
- granoladb-0.1.0/.github/workflows/ci.yml +14 -0
- granoladb-0.1.0/.gitignore +11 -0
- granoladb-0.1.0/LICENSE +21 -0
- granoladb-0.1.0/PKG-INFO +140 -0
- granoladb-0.1.0/README.md +110 -0
- granoladb-0.1.0/docs/granola-internal-api.md +98 -0
- granoladb-0.1.0/examples/demo.py +37 -0
- granoladb-0.1.0/examples/seed_database.py +131 -0
- granoladb-0.1.0/examples/seed_demo.py +56 -0
- granoladb-0.1.0/pyproject.toml +41 -0
- granoladb-0.1.0/src/granoladb/__init__.py +28 -0
- granoladb-0.1.0/src/granoladb/auth.py +91 -0
- granoladb-0.1.0/src/granoladb/backend.py +27 -0
- granoladb-0.1.0/src/granoladb/backend_granola.py +133 -0
- granoladb-0.1.0/src/granoladb/buffer.py +39 -0
- granoladb-0.1.0/src/granoladb/capture_macos.py +128 -0
- granoladb-0.1.0/src/granoladb/cli.py +75 -0
- granoladb-0.1.0/src/granoladb/client.py +53 -0
- granoladb-0.1.0/src/granoladb/codec.py +33 -0
- granoladb-0.1.0/src/granoladb/keyring_macos.py +148 -0
- granoladb-0.1.0/src/granoladb/logging_handler.py +33 -0
- granoladb-0.1.0/src/granoladb/otel.py +34 -0
- granoladb-0.1.0/src/granoladb/ydoc.py +46 -0
- granoladb-0.1.0/tests/__init__.py +0 -0
- granoladb-0.1.0/tests/test_auth.py +78 -0
- granoladb-0.1.0/tests/test_backend_fake.py +15 -0
- granoladb-0.1.0/tests/test_backend_granola.py +80 -0
- granoladb-0.1.0/tests/test_buffer.py +25 -0
- granoladb-0.1.0/tests/test_capture_macos.py +44 -0
- granoladb-0.1.0/tests/test_cli.py +54 -0
- granoladb-0.1.0/tests/test_client.py +41 -0
- granoladb-0.1.0/tests/test_codec.py +17 -0
- granoladb-0.1.0/tests/test_keyring_macos.py +66 -0
- granoladb-0.1.0/tests/test_logging_handler.py +34 -0
- granoladb-0.1.0/tests/test_otel.py +23 -0
- granoladb-0.1.0/tests/test_ydoc.py +32 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
env:
|
|
7
|
+
GRANOLADB_DRY_RUN: "1"
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v4
|
|
10
|
+
- uses: actions/setup-python@v5
|
|
11
|
+
with:
|
|
12
|
+
python-version: "3.11"
|
|
13
|
+
- run: pip install -e '.[dev,otel]'
|
|
14
|
+
- run: pytest -q
|
granoladb-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GranolaDB contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
granoladb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: granoladb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Store your logs and agent traces in Granola. The meeting-notes app. Yes, really.
|
|
5
|
+
Project-URL: Homepage, https://github.com/that-guy-wade/granoladb
|
|
6
|
+
Project-URL: Repository, https://github.com/that-guy-wade/granoladb
|
|
7
|
+
Author: GranolaDB
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: database,granola,logging,observability,satire
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Topic :: System :: Logging
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: keyring>=24
|
|
18
|
+
Requires-Dist: pycrdt>=0.14
|
|
19
|
+
Provides-Extra: capture
|
|
20
|
+
Requires-Dist: mitmproxy>=11; extra == 'capture'
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: cryptography>=42; extra == 'dev'
|
|
23
|
+
Requires-Dist: mitmproxy>=11; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
25
|
+
Provides-Extra: login
|
|
26
|
+
Requires-Dist: cryptography>=42; extra == 'login'
|
|
27
|
+
Provides-Extra: otel
|
|
28
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'otel'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# GranolaDB
|
|
32
|
+
|
|
33
|
+
> The database that runs on your meeting notes.
|
|
34
|
+
|
|
35
|
+
Stop paying for S3, CloudWatch, and Datadog. GranolaDB stores your logs and agent
|
|
36
|
+
traces in [Granola](https://granola.ai) — the AI meeting-notes app — as meeting
|
|
37
|
+
notes. Infinitely scalable*. Fully searchable**. Your traces finally get an AI
|
|
38
|
+
summary.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install granoladb # core
|
|
44
|
+
pip install granoladb[otel] # + OpenTelemetry span exporter
|
|
45
|
+
pip install granoladb[capture] # + `granoladb login --capture` (macOS)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then get your Granola access token. On macOS, GranolaDB grabs it for you:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
granoladb login --capture
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This intercepts your own Granola app's traffic (via mitmproxy's local mode) and
|
|
55
|
+
lifts the `Authorization: Bearer` token out of it — because Granola encrypts the
|
|
56
|
+
token on disk and its network client ignores every proxy, so there's genuinely no
|
|
57
|
+
politer way to get it. You'll approve a one-time macOS network-extension prompt,
|
|
58
|
+
then click around a couple of notes so the app makes a request. Yes: **GranolaDB
|
|
59
|
+
MITMs your own Granola to obtain a token Granola won't hand out.** That is the joke,
|
|
60
|
+
and it is load-bearing.
|
|
61
|
+
|
|
62
|
+
The token is a short-lived session JWT (expires in a few hours) — rerun `--capture`
|
|
63
|
+
when it does. Or, if you have a token another way, cache it directly:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
granoladb login <YOUR_TOKEN> # store it (OS keychain)
|
|
67
|
+
export GRANOLA_TOKEN="..." # or per-shell, nothing stored
|
|
68
|
+
granoladb logout # forget the stored token
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
(There's also an experimental `granoladb login --auto` that tries to decrypt the
|
|
72
|
+
token straight from the app's local store, but Granola's on-disk crypto changes
|
|
73
|
+
often, so don't count on it.)
|
|
74
|
+
|
|
75
|
+
### Exactly how `--capture` works (no hidden magic)
|
|
76
|
+
|
|
77
|
+
We think you should know precisely what this does before running it:
|
|
78
|
+
|
|
79
|
+
1. It launches **mitmproxy in "local mode"** pointed at the **Granola process** only.
|
|
80
|
+
mitmproxy installs a **macOS network extension** ("Mitmproxy Redirector") that you
|
|
81
|
+
approve once — it reroutes *only Granola's* traffic through mitmproxy.
|
|
82
|
+
2. **Why intercept at all:** Granola encrypts the token on disk and its API client
|
|
83
|
+
ignores every proxy (system, env, VPN), so there is no file to read or setting to
|
|
84
|
+
flip. Intercepting the app's own traffic is the only way to obtain it.
|
|
85
|
+
3. **What it reads:** when Granola calls `api.granola.ai`, the request carries an
|
|
86
|
+
`Authorization: Bearer <token>` header. `--capture` extracts **only** that value.
|
|
87
|
+
4. **Where it goes:** stored **locally only** — in your **OS keychain** (macOS
|
|
88
|
+
Keychain / Windows Credential Manager / Freedesktop Secret Service) via the
|
|
89
|
+
`keyring` library. If no keychain backend exists, it falls back to a `0600` file
|
|
90
|
+
at `~/.granoladb/token` and tells you it did so. **Nothing is sent anywhere.**
|
|
91
|
+
The mitmproxy process and its temporary capture file are torn down immediately.
|
|
92
|
+
5. **What the token is:** your own short-lived Granola **session JWT** — not your
|
|
93
|
+
password, not a refresh token. `granoladb logout` deletes it.
|
|
94
|
+
|
|
95
|
+
The whole capture flow is in [`src/granoladb/capture_macos.py`](src/granoladb/capture_macos.py)
|
|
96
|
+
and storage is in [`src/granoladb/auth.py`](src/granoladb/auth.py) — read them.
|
|
97
|
+
|
|
98
|
+
## Quickstart
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import logging, granoladb
|
|
102
|
+
|
|
103
|
+
logging.getLogger().addHandler(granoladb.GranolaHandler())
|
|
104
|
+
logging.error("payment failed") # this is now a meeting
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from granoladb import Client
|
|
109
|
+
db = Client()
|
|
110
|
+
db.put({"level": "ERROR", "msg": "payment failed", "svc": "api"})
|
|
111
|
+
print(db.query(contains="payment"))
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## vs. legacy databases
|
|
115
|
+
|
|
116
|
+
| Feature | Postgres | S3 | GranolaDB |
|
|
117
|
+
| ------------------ | -------- | ---- | ----------------------- |
|
|
118
|
+
| Durability | Yes | Yes | "probably" |
|
|
119
|
+
| Query latency | ms | ms | one flush interval |
|
|
120
|
+
| AI summary of rows | No | No | **Yes** |
|
|
121
|
+
| Retention policy | You set | You | when Granola archives it|
|
|
122
|
+
| Production ready | Yes | Yes | **absolutely not** |
|
|
123
|
+
|
|
124
|
+
\* limited by Granola's rate limits, which we call horizontal scaling.
|
|
125
|
+
\** limited by Granola's search bar.
|
|
126
|
+
|
|
127
|
+
## How it works
|
|
128
|
+
|
|
129
|
+
Records are batched into "meetings." Each batch becomes one Granola document — JSON
|
|
130
|
+
lines under a `GRANOLADB v1` marker, written via Granola's internal
|
|
131
|
+
`/v1/create-document` endpoint and read back via `/v1/get-documents`. Reads decode
|
|
132
|
+
the records out. This is a joke. Do not put anything real in it.
|
|
133
|
+
|
|
134
|
+
Auth: `granoladb login --capture` (see above) stores your token in the OS keychain,
|
|
135
|
+
or set `GRANOLA_TOKEN`. Set `GRANOLADB_DRY_RUN=1` to print calls instead of writing
|
|
136
|
+
to Granola.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# GranolaDB
|
|
2
|
+
|
|
3
|
+
> The database that runs on your meeting notes.
|
|
4
|
+
|
|
5
|
+
Stop paying for S3, CloudWatch, and Datadog. GranolaDB stores your logs and agent
|
|
6
|
+
traces in [Granola](https://granola.ai) — the AI meeting-notes app — as meeting
|
|
7
|
+
notes. Infinitely scalable*. Fully searchable**. Your traces finally get an AI
|
|
8
|
+
summary.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install granoladb # core
|
|
14
|
+
pip install granoladb[otel] # + OpenTelemetry span exporter
|
|
15
|
+
pip install granoladb[capture] # + `granoladb login --capture` (macOS)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then get your Granola access token. On macOS, GranolaDB grabs it for you:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
granoladb login --capture
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This intercepts your own Granola app's traffic (via mitmproxy's local mode) and
|
|
25
|
+
lifts the `Authorization: Bearer` token out of it — because Granola encrypts the
|
|
26
|
+
token on disk and its network client ignores every proxy, so there's genuinely no
|
|
27
|
+
politer way to get it. You'll approve a one-time macOS network-extension prompt,
|
|
28
|
+
then click around a couple of notes so the app makes a request. Yes: **GranolaDB
|
|
29
|
+
MITMs your own Granola to obtain a token Granola won't hand out.** That is the joke,
|
|
30
|
+
and it is load-bearing.
|
|
31
|
+
|
|
32
|
+
The token is a short-lived session JWT (expires in a few hours) — rerun `--capture`
|
|
33
|
+
when it does. Or, if you have a token another way, cache it directly:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
granoladb login <YOUR_TOKEN> # store it (OS keychain)
|
|
37
|
+
export GRANOLA_TOKEN="..." # or per-shell, nothing stored
|
|
38
|
+
granoladb logout # forget the stored token
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
(There's also an experimental `granoladb login --auto` that tries to decrypt the
|
|
42
|
+
token straight from the app's local store, but Granola's on-disk crypto changes
|
|
43
|
+
often, so don't count on it.)
|
|
44
|
+
|
|
45
|
+
### Exactly how `--capture` works (no hidden magic)
|
|
46
|
+
|
|
47
|
+
We think you should know precisely what this does before running it:
|
|
48
|
+
|
|
49
|
+
1. It launches **mitmproxy in "local mode"** pointed at the **Granola process** only.
|
|
50
|
+
mitmproxy installs a **macOS network extension** ("Mitmproxy Redirector") that you
|
|
51
|
+
approve once — it reroutes *only Granola's* traffic through mitmproxy.
|
|
52
|
+
2. **Why intercept at all:** Granola encrypts the token on disk and its API client
|
|
53
|
+
ignores every proxy (system, env, VPN), so there is no file to read or setting to
|
|
54
|
+
flip. Intercepting the app's own traffic is the only way to obtain it.
|
|
55
|
+
3. **What it reads:** when Granola calls `api.granola.ai`, the request carries an
|
|
56
|
+
`Authorization: Bearer <token>` header. `--capture` extracts **only** that value.
|
|
57
|
+
4. **Where it goes:** stored **locally only** — in your **OS keychain** (macOS
|
|
58
|
+
Keychain / Windows Credential Manager / Freedesktop Secret Service) via the
|
|
59
|
+
`keyring` library. If no keychain backend exists, it falls back to a `0600` file
|
|
60
|
+
at `~/.granoladb/token` and tells you it did so. **Nothing is sent anywhere.**
|
|
61
|
+
The mitmproxy process and its temporary capture file are torn down immediately.
|
|
62
|
+
5. **What the token is:** your own short-lived Granola **session JWT** — not your
|
|
63
|
+
password, not a refresh token. `granoladb logout` deletes it.
|
|
64
|
+
|
|
65
|
+
The whole capture flow is in [`src/granoladb/capture_macos.py`](src/granoladb/capture_macos.py)
|
|
66
|
+
and storage is in [`src/granoladb/auth.py`](src/granoladb/auth.py) — read them.
|
|
67
|
+
|
|
68
|
+
## Quickstart
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import logging, granoladb
|
|
72
|
+
|
|
73
|
+
logging.getLogger().addHandler(granoladb.GranolaHandler())
|
|
74
|
+
logging.error("payment failed") # this is now a meeting
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from granoladb import Client
|
|
79
|
+
db = Client()
|
|
80
|
+
db.put({"level": "ERROR", "msg": "payment failed", "svc": "api"})
|
|
81
|
+
print(db.query(contains="payment"))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## vs. legacy databases
|
|
85
|
+
|
|
86
|
+
| Feature | Postgres | S3 | GranolaDB |
|
|
87
|
+
| ------------------ | -------- | ---- | ----------------------- |
|
|
88
|
+
| Durability | Yes | Yes | "probably" |
|
|
89
|
+
| Query latency | ms | ms | one flush interval |
|
|
90
|
+
| AI summary of rows | No | No | **Yes** |
|
|
91
|
+
| Retention policy | You set | You | when Granola archives it|
|
|
92
|
+
| Production ready | Yes | Yes | **absolutely not** |
|
|
93
|
+
|
|
94
|
+
\* limited by Granola's rate limits, which we call horizontal scaling.
|
|
95
|
+
\** limited by Granola's search bar.
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
Records are batched into "meetings." Each batch becomes one Granola document — JSON
|
|
100
|
+
lines under a `GRANOLADB v1` marker, written via Granola's internal
|
|
101
|
+
`/v1/create-document` endpoint and read back via `/v1/get-documents`. Reads decode
|
|
102
|
+
the records out. This is a joke. Do not put anything real in it.
|
|
103
|
+
|
|
104
|
+
Auth: `granoladb login --capture` (see above) stores your token in the OS keychain,
|
|
105
|
+
or set `GRANOLA_TOKEN`. Set `GRANOLADB_DRY_RUN=1` to print calls instead of writing
|
|
106
|
+
to Granola.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Granola internal API (reverse-engineered)
|
|
2
|
+
|
|
3
|
+
Source: strings + request wrapper extracted from the Granola macOS app bundle
|
|
4
|
+
(`/Applications/Granola.app/Contents/Resources/app.asar`, build dated 2026-08-26).
|
|
5
|
+
No live traffic capture was needed — the endpoints and request shape are in the
|
|
6
|
+
bundled JS. (A mitmproxy attempt captured nothing: Granola is Electron and Node
|
|
7
|
+
uses its own CA bundle, so a system-keychain-trusted mitmproxy cert is bypassed.)
|
|
8
|
+
|
|
9
|
+
## Base
|
|
10
|
+
|
|
11
|
+
`https://api.granola.ai`
|
|
12
|
+
|
|
13
|
+
## Request wrapper (from `api-*.js`)
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
function z(client, path, { input, method, ... }) {
|
|
17
|
+
fetch(getAPIEndpoint(path), {
|
|
18
|
+
method: method ?? "POST",
|
|
19
|
+
headers: { ...authHeaders, ...(input ? {"Content-Type": "application/json"} : {}) },
|
|
20
|
+
body: input ? JSON.stringify(input) : null,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- The `input` object is sent as the **raw JSON body** (no `{input: ...}` envelope).
|
|
26
|
+
- Auth header: **`Authorization: Bearer <accessToken>`**.
|
|
27
|
+
|
|
28
|
+
## Write (confirmed by live capture, 2026-08-28)
|
|
29
|
+
|
|
30
|
+
The desktop app creates a note in **two calls**: an empty skeleton, then a content
|
|
31
|
+
fill. GranolaDB mirrors this exactly.
|
|
32
|
+
|
|
33
|
+
**1. Resolve workspace** — `POST /v1/get-workspaces` with body `{}`. Response:
|
|
34
|
+
`{ "workspaces": [ { "workspace": { "workspace_id": "<uuid>", ... }, "role", "plan_type" } ] }`.
|
|
35
|
+
Use `workspaces[0].workspace.workspace_id`.
|
|
36
|
+
|
|
37
|
+
**2. Create empty document** — `POST /v1/create-document`. The primary key is `id`
|
|
38
|
+
(a client-generated uuid), **not** `document_id`. On create, `title`/`notes*` are
|
|
39
|
+
`null`; the app sends this skeleton (constant values observed):
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"id": "<client uuid>",
|
|
44
|
+
"created_at": "<iso>", "updated_at": "<iso>",
|
|
45
|
+
"type": "meeting",
|
|
46
|
+
"creation_source": "macOS",
|
|
47
|
+
"meeting_end_count": 0,
|
|
48
|
+
"public": false,
|
|
49
|
+
"show_private_notes": false,
|
|
50
|
+
"transcribe": true,
|
|
51
|
+
"sharing_link_visibility": "public",
|
|
52
|
+
"workspace_id": "<uuid from step 1>"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Response: `{ "id": "<id>" }`, status 200. (GranolaDB sends `transcribe: false` — it
|
|
57
|
+
never records audio.)
|
|
58
|
+
|
|
59
|
+
**3. Fill content** — `POST /v1/update-document`:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "id": "<id>", "title": "<title>", "notes_plain": "<body>", "notes_markdown": "<body>", "updated_at": "<iso>" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
(The app also sends `ydoc_state`/`ydoc_version` — a Yjs CRDT for the rich editor.
|
|
66
|
+
GranolaDB omits them; `notes_markdown`/`notes_plain` are enough to store and read
|
|
67
|
+
records back. The note may render empty in the Granola editor while still holding
|
|
68
|
+
our data in `notes_markdown`.)
|
|
69
|
+
|
|
70
|
+
`document_id` (seen 536× in the bundle) is the field name on *read/reference*
|
|
71
|
+
endpoints; the create/update primary key is `id`.
|
|
72
|
+
|
|
73
|
+
## Read
|
|
74
|
+
|
|
75
|
+
- `POST /v1/get-documents` — returns the caller's documents. (A `/v2/get-documents`
|
|
76
|
+
also exists in older reverse-engineering notes.) Response is normalized in code
|
|
77
|
+
by reading `docs`/`documents` and each doc's `notes_markdown`/`notes_plain`.
|
|
78
|
+
|
|
79
|
+
## Auth (decided: env token)
|
|
80
|
+
|
|
81
|
+
The app sends `Authorization: Bearer <accessToken>`. In this build the token is
|
|
82
|
+
**not** stored in plaintext:
|
|
83
|
+
|
|
84
|
+
- `cache-v6.json.enc` — encrypted via Electron safeStorage (macOS Keychain-derived
|
|
85
|
+
key). Not JSON, not readable without the Keychain secret.
|
|
86
|
+
- Chromium `Cookies` DB — also safeStorage-encrypted on macOS.
|
|
87
|
+
- WorkOS AuthKit (`user_management/sessions`) is the identity provider.
|
|
88
|
+
|
|
89
|
+
Because there is no plaintext token on disk, **GranolaDB v1 takes the token from
|
|
90
|
+
the `GRANOLA_TOKEN` environment variable** and uses it as the Bearer token. No
|
|
91
|
+
WorkOS exchange and no `client_id` are needed — we reuse the app's existing access
|
|
92
|
+
token. (A future `granoladb login` could decrypt safeStorage for zero-config auth;
|
|
93
|
+
out of scope for v1.)
|
|
94
|
+
|
|
95
|
+
## Never commit token values
|
|
96
|
+
|
|
97
|
+
Only endpoint/field/key *names* are recorded here. No access token, refresh token,
|
|
98
|
+
or cookie value has been read into or written to this repo.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Live GranolaDB demo, paced for screen recording.
|
|
2
|
+
|
|
3
|
+
Writes a readable log note to Granola so a note visibly appears while recording.
|
|
4
|
+
Run: python examples/demo.py
|
|
5
|
+
Then cut to Granola to show the note, and open the demo folder to query the AI.
|
|
6
|
+
"""
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from granoladb.backend_granola import GranolaBackend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def line(text="", pause=0.8):
|
|
13
|
+
print(text)
|
|
14
|
+
time.sleep(pause)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
be = GranolaBackend()
|
|
19
|
+
line("GranolaDB — a database that runs on your meeting notes.", 1.2)
|
|
20
|
+
line("Storing this app's backend logs... in Granola.\n", 1.2)
|
|
21
|
+
|
|
22
|
+
body = "\n".join([
|
|
23
|
+
"backend service — live logs", "",
|
|
24
|
+
"2026-08-31T10:02:01Z INFO api POST /v1/checkout 200 (38ms)",
|
|
25
|
+
"2026-08-31T10:02:03Z WARN api p99 latency 771ms above target",
|
|
26
|
+
"2026-08-31T10:02:05Z ERROR payments charge declined user_id=2 (card_declined)",
|
|
27
|
+
"2026-08-31T10:02:06Z INFO api order 1005 shipped $78.25",
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
line("→ granoladb: writing 4 log lines...", 1.4)
|
|
31
|
+
be.create_document("backend — live demo", body)
|
|
32
|
+
line("✓ stored as a Granola note.\n", 1.2)
|
|
33
|
+
line("Open Granola. Your logs are now a meeting.", 0.4)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Seed a full GranolaDB 'database' demo into Granola.
|
|
2
|
+
|
|
3
|
+
Creates one folder containing:
|
|
4
|
+
- table docs (users, orders) rendered as readable tables
|
|
5
|
+
- per-service log docs (backend, frontend, auth, worker)
|
|
6
|
+
|
|
7
|
+
Every doc is written with real editor content (ydoc), so it shows up in Granola
|
|
8
|
+
AND is answerable by Granola's own AI. Re-running first deletes the previous demo
|
|
9
|
+
docs so it stays idempotent. Delete the folder (and its docs) to clean up.
|
|
10
|
+
|
|
11
|
+
Run: granoladb login --capture # fresh token
|
|
12
|
+
python examples/seed_database.py
|
|
13
|
+
"""
|
|
14
|
+
import gzip
|
|
15
|
+
import json
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
from granoladb.backend_granola import GranolaBackend, BASE
|
|
20
|
+
|
|
21
|
+
FOLDER_TITLE = "GranolaDB (demo — safe to delete)"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _call(be, path, payload):
|
|
25
|
+
req = urllib.request.Request(
|
|
26
|
+
BASE + path, data=json.dumps(payload).encode(),
|
|
27
|
+
headers={"Authorization": f"Bearer {be._access()}", "Content-Type": "application/json"},
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
raw = urllib.request.urlopen(req, timeout=25).read()
|
|
31
|
+
except urllib.error.HTTPError as e:
|
|
32
|
+
return e.code, None
|
|
33
|
+
if raw[:2] == b"\x1f\x8b":
|
|
34
|
+
raw = gzip.decompress(raw)
|
|
35
|
+
return 200, (json.loads(raw) if raw.strip() else {})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_folder(be):
|
|
39
|
+
_, meta = _call(be, "/v1/get-document-lists-metadata", {})
|
|
40
|
+
lists = meta.get("lists", {}) if isinstance(meta, dict) else {}
|
|
41
|
+
for key, m in lists.items():
|
|
42
|
+
if isinstance(m, dict) and m.get("title") == FOLDER_TITLE:
|
|
43
|
+
return m.get("id") or key
|
|
44
|
+
import uuid
|
|
45
|
+
lid = str(uuid.uuid4())
|
|
46
|
+
_call(be, "/v1/create-document-list-v2",
|
|
47
|
+
{"id": lid, "title": FOLDER_TITLE, "workspace_id": be._resolve_workspace()})
|
|
48
|
+
return lid
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def delete_prior_demo_docs(be, titles):
|
|
52
|
+
_, r = _call(be, "/v1/get-documents", {})
|
|
53
|
+
arr = r if isinstance(r, list) else (r.get("docs") or r.get("documents") or []) if isinstance(r, dict) else []
|
|
54
|
+
n = 0
|
|
55
|
+
for d in arr:
|
|
56
|
+
if d.get("title") in titles:
|
|
57
|
+
code, _ = _call(be, "/v1/hard-delete-document", {"document_id": d["id"]})
|
|
58
|
+
if code == 200:
|
|
59
|
+
n += 1
|
|
60
|
+
return n
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def table(title, headers, rows):
|
|
64
|
+
widths = [max(len(str(x)) for x in [h] + [r[i] for r in rows]) for i, h in enumerate(headers)]
|
|
65
|
+
def fmt(cells):
|
|
66
|
+
return " | ".join(str(c).ljust(widths[i]) for i, c in enumerate(cells))
|
|
67
|
+
lines = [title, "", fmt(headers)] + [fmt(r) for r in rows]
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main():
|
|
72
|
+
be = GranolaBackend()
|
|
73
|
+
be._resolve_workspace()
|
|
74
|
+
|
|
75
|
+
docs = {
|
|
76
|
+
"users (table)": table(
|
|
77
|
+
"users (GranolaDB table)",
|
|
78
|
+
["id", "name", "email", "plan", "signup"],
|
|
79
|
+
[[1, "Ada Lovelace", "ada@calc.io", "pro", "2026-01-04"],
|
|
80
|
+
[2, "Alan Turing", "alan@enigma.uk", "free", "2026-02-11"],
|
|
81
|
+
[3, "Grace Hopper", "grace@navy.mil", "pro", "2026-03-02"],
|
|
82
|
+
[4, "Katherine Johnson", "kj@nasa.gov", "enterprise", "2026-03-19"]]),
|
|
83
|
+
"orders (table)": table(
|
|
84
|
+
"orders (GranolaDB table)",
|
|
85
|
+
["id", "user_id", "item", "amount", "status"],
|
|
86
|
+
[[1001, 1, "Keyboard", "$89.00", "shipped"],
|
|
87
|
+
[1002, 3, "Monitor", "$412.50", "shipped"],
|
|
88
|
+
[1003, 2, "Mouse", "$24.99", "refunded"],
|
|
89
|
+
[1004, 1, "Standing desk", "$611.00", "processing"],
|
|
90
|
+
[1005, 4, "Webcam", "$78.25", "shipped"]]),
|
|
91
|
+
"backend — logs": "\n".join([
|
|
92
|
+
"backend service logs", "",
|
|
93
|
+
"2026-08-30T09:01:02Z INFO api GET /v1/orders 200 (41ms)",
|
|
94
|
+
"2026-08-30T09:01:04Z WARN api p99 latency 812ms exceeds 500ms target",
|
|
95
|
+
"2026-08-30T09:01:07Z ERROR payments charge failed: card_declined user_id=2",
|
|
96
|
+
"2026-08-30T09:01:09Z INFO api POST /v1/orders 201 order_id=1005",
|
|
97
|
+
"2026-08-30T09:02:15Z ERROR db connection pool exhausted (max=20)"]),
|
|
98
|
+
"frontend — logs": "\n".join([
|
|
99
|
+
"frontend service logs", "",
|
|
100
|
+
"2026-08-30T09:00:59Z INFO ui route change /dashboard",
|
|
101
|
+
"2026-08-30T09:01:03Z WARN ui slow render OrdersTable 240ms",
|
|
102
|
+
"2026-08-30T09:01:05Z ERROR ui Uncaught TypeError: cart is undefined (checkout.tsx:88)",
|
|
103
|
+
"2026-08-30T09:01:12Z INFO ui user clicked 'retry payment'"]),
|
|
104
|
+
"auth-service — logs": "\n".join([
|
|
105
|
+
"auth-service logs", "",
|
|
106
|
+
"2026-08-30T09:00:40Z INFO auth login success user_id=1 mfa=totp",
|
|
107
|
+
"2026-08-30T09:00:51Z WARN auth 3 failed logins for alan@enigma.uk",
|
|
108
|
+
"2026-08-30T09:01:00Z INFO auth token refreshed user_id=3",
|
|
109
|
+
"2026-08-30T09:03:22Z ERROR auth JWT signature verification failed"]),
|
|
110
|
+
"worker-service — logs": "\n".join([
|
|
111
|
+
"worker-service logs", "",
|
|
112
|
+
"2026-08-30T09:00:10Z INFO worker drained queue: 14,204 jobs overnight",
|
|
113
|
+
"2026-08-30T09:01:30Z WARN worker retrying job 88213 (attempt 3)",
|
|
114
|
+
"2026-08-30T09:02:05Z ERROR worker job 88213 dead-lettered after 5 attempts",
|
|
115
|
+
"2026-08-30T09:04:00Z INFO worker nightly export uploaded to s3://reports/2026-08-30"]),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
removed = delete_prior_demo_docs(be, set(docs) | {"ydoc-test: users"})
|
|
119
|
+
print(f"cleaned up {removed} prior demo doc(s)")
|
|
120
|
+
|
|
121
|
+
folder = ensure_folder(be)
|
|
122
|
+
print(f"folder: {FOLDER_TITLE}")
|
|
123
|
+
for title, body in docs.items():
|
|
124
|
+
doc_id = be.create_document(title, body)
|
|
125
|
+
_call(be, "/v1/add-document-to-list", {"document_list_id": folder, "document_id": doc_id})
|
|
126
|
+
print(f" + {title}")
|
|
127
|
+
print("done — open the folder in Granola and query it with the AI.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Seed a couple of fake GranolaDB "records" so Granola's AI has something to
|
|
2
|
+
summarize. Run with a real token:
|
|
3
|
+
|
|
4
|
+
GRANOLA_TOKEN="your-granola-token" python examples/seed_demo.py
|
|
5
|
+
|
|
6
|
+
Each batch below becomes ONE Granola note (a "meeting"), so you can open it in
|
|
7
|
+
Granola and ask the AI things like:
|
|
8
|
+
- "What happened in this meeting?"
|
|
9
|
+
- "What was the root cause?"
|
|
10
|
+
- "How much did the agent spend?"
|
|
11
|
+
"""
|
|
12
|
+
import granoladb
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def seed_incident(db):
|
|
16
|
+
"""A fake AI shopping-agent incident, told as log lines + trace spans."""
|
|
17
|
+
for rec in [
|
|
18
|
+
{"kind": "span", "name": "agent.plan", "level": "INFO", "svc": "shop-agent",
|
|
19
|
+
"msg": "user asked: buy running shoes under $100"},
|
|
20
|
+
{"level": "INFO", "svc": "shop-agent", "msg": "searching catalog for 'running shoes'"},
|
|
21
|
+
{"level": "WARN", "svc": "shop-agent",
|
|
22
|
+
"msg": "price filter <$100 returned 0 results; relaxing constraints"},
|
|
23
|
+
{"level": "ERROR", "svc": "shop-agent",
|
|
24
|
+
"msg": "constraint relaxation loop: added 47 items to cart"},
|
|
25
|
+
{"kind": "span", "name": "checkout", "level": "INFO", "svc": "shop-agent",
|
|
26
|
+
"msg": "cart total $3,812.44 across 47 items"},
|
|
27
|
+
{"level": "ERROR", "svc": "payments", "msg": "charge declined: insufficient funds"},
|
|
28
|
+
{"level": "INFO", "svc": "shop-agent",
|
|
29
|
+
"msg": "agent apologized to user and abandoned the task"},
|
|
30
|
+
]:
|
|
31
|
+
db.put(rec)
|
|
32
|
+
db.flush() # one note
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def seed_standup(db):
|
|
36
|
+
"""A mundane 'daily standup' of service logs."""
|
|
37
|
+
for rec in [
|
|
38
|
+
{"level": "INFO", "svc": "api", "msg": "deploy v2.3.1 shipped, 0 rollbacks"},
|
|
39
|
+
{"level": "WARN", "svc": "api", "msg": "p99 latency 812ms, above 500ms target"},
|
|
40
|
+
{"level": "INFO", "svc": "worker", "msg": "queue drained, 14k jobs processed overnight"},
|
|
41
|
+
{"level": "ERROR", "svc": "billing", "msg": "3 invoices failed to generate, retrying"},
|
|
42
|
+
]:
|
|
43
|
+
db.put(rec)
|
|
44
|
+
db.flush() # one note
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
# High flush_size so each seed_* function's batch lands in a single note.
|
|
49
|
+
db = granoladb.Client(flush_size=1000, flush_interval=None)
|
|
50
|
+
seed_incident(db)
|
|
51
|
+
seed_standup(db)
|
|
52
|
+
print("Seeded 2 notes into Granola. Open them and ask the AI about them.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "granoladb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Store your logs and agent traces in Granola. The meeting-notes app. Yes, really."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "GranolaDB" }]
|
|
13
|
+
keywords = ["observability", "logging", "database", "granola", "satire"]
|
|
14
|
+
dependencies = ["pycrdt>=0.14", "keyring>=24"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: System :: Logging",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/that-guy-wade/granoladb"
|
|
25
|
+
Repository = "https://github.com/that-guy-wade/granoladb"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
otel = ["opentelemetry-sdk>=1.20"]
|
|
29
|
+
login = ["cryptography>=42"]
|
|
30
|
+
capture = ["mitmproxy>=11"]
|
|
31
|
+
dev = ["pytest>=8", "cryptography>=42", "mitmproxy>=11"]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
granoladb = "granoladb.cli:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/granoladb"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
pythonpath = ["src"]
|
|
41
|
+
testpaths = ["tests"]
|