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.
Files changed (36) hide show
  1. granoladb-0.1.0/.github/workflows/ci.yml +14 -0
  2. granoladb-0.1.0/.gitignore +11 -0
  3. granoladb-0.1.0/LICENSE +21 -0
  4. granoladb-0.1.0/PKG-INFO +140 -0
  5. granoladb-0.1.0/README.md +110 -0
  6. granoladb-0.1.0/docs/granola-internal-api.md +98 -0
  7. granoladb-0.1.0/examples/demo.py +37 -0
  8. granoladb-0.1.0/examples/seed_database.py +131 -0
  9. granoladb-0.1.0/examples/seed_demo.py +56 -0
  10. granoladb-0.1.0/pyproject.toml +41 -0
  11. granoladb-0.1.0/src/granoladb/__init__.py +28 -0
  12. granoladb-0.1.0/src/granoladb/auth.py +91 -0
  13. granoladb-0.1.0/src/granoladb/backend.py +27 -0
  14. granoladb-0.1.0/src/granoladb/backend_granola.py +133 -0
  15. granoladb-0.1.0/src/granoladb/buffer.py +39 -0
  16. granoladb-0.1.0/src/granoladb/capture_macos.py +128 -0
  17. granoladb-0.1.0/src/granoladb/cli.py +75 -0
  18. granoladb-0.1.0/src/granoladb/client.py +53 -0
  19. granoladb-0.1.0/src/granoladb/codec.py +33 -0
  20. granoladb-0.1.0/src/granoladb/keyring_macos.py +148 -0
  21. granoladb-0.1.0/src/granoladb/logging_handler.py +33 -0
  22. granoladb-0.1.0/src/granoladb/otel.py +34 -0
  23. granoladb-0.1.0/src/granoladb/ydoc.py +46 -0
  24. granoladb-0.1.0/tests/__init__.py +0 -0
  25. granoladb-0.1.0/tests/test_auth.py +78 -0
  26. granoladb-0.1.0/tests/test_backend_fake.py +15 -0
  27. granoladb-0.1.0/tests/test_backend_granola.py +80 -0
  28. granoladb-0.1.0/tests/test_buffer.py +25 -0
  29. granoladb-0.1.0/tests/test_capture_macos.py +44 -0
  30. granoladb-0.1.0/tests/test_cli.py +54 -0
  31. granoladb-0.1.0/tests/test_client.py +41 -0
  32. granoladb-0.1.0/tests/test_codec.py +17 -0
  33. granoladb-0.1.0/tests/test_keyring_macos.py +66 -0
  34. granoladb-0.1.0/tests/test_logging_handler.py +34 -0
  35. granoladb-0.1.0/tests/test_otel.py +23 -0
  36. 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
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .env
8
+ .env.*
9
+ .DS_Store
10
+ docs/superpowers/
11
+ launch-thread.md
@@ -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.
@@ -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"]