fix(notion): delete verb, one trash bit, row cells, and stale docs (#747)

DELETE /v1/blocks/{id} is the only delete verb the public API has and the
only one the MCP tool surface exposes, but the fake had no route for it
and `ntn api` had no DELETE method, so nothing could remove anything.

`archived` is upstream's deprecated alias for `in_trash` and always
returns the same value; the fake stored two columns, so a PATCH of
`archived` left the row in database queries while `ntn pages trash`
(in_trash) worked. Now one stored bit, both spellings on the wire.

A database row's cells now ride in its page.json under `properties`.

Also: trashing a child page moved only the page row, leaving the
child_page block in the parent listing; `datasources query` took its
TSV columns from the schema where upstream takes them from the returned
rows; and two MCP-parity cases pointed at pre-data-source paths, so they
compared two identical errors and asserted nothing.

Docs: both ntn.mdx files documented the pre-#740 grammar (--page flags,
ntn blocks/comments/search). notion.mdx, the TS setup doc and both
examples predated the data-source split; the examples were broken.
This commit is contained in:
Zecheng Zhang
2026-08-10 02:42:29 -07:00
committed by GitHub
parent 003753c21a
commit 7fbe3aedb8
26 changed files with 803 additions and 224 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ description: Set up a Notion internal integration for the Notion resource.
1. Under **Content Capabilities**, enable:
- **Read content** (required)
- **Update content** (for write commands)
- **Insert content** (for `ntn comments create`)
- **Insert content** (for creating pages and comments)
1. Click **Save changes**
1. Copy the **Internal Integration Secret** (starts with `ntn_`)
+94 -33
View File
@@ -5,7 +5,7 @@ icon: terminal
---
Notion API client following the official Notion CLI grammar. Install it on the workspace and the whole tree is
discoverable with `ntn --help`.
discoverable with `ntn --help` or `man ntn`.
## Install
@@ -25,46 +25,107 @@ install rides the `clis:` section; see the [CLI overview](/python/cli/index).
## Verbs
The grammar follows the official
[Notion CLI](https://developers.notion.com/cli) (`ntn pages
get/create/edit/trash`, `ntn datasources query`); `blocks`, `comments`
and `search` are mirage extensions spelled with the REST API's nouns.
JSON bodies are the raw Notion API resources.
The grammar matches the official
[Notion CLI](https://developers.notion.com/cli) verb for verb, and every
case is gated against the real `ntn` binary in CI, so what is written
here is what the program does.
**Ids are positional, not flags.** There is no `--page`, `--block` or
`--datasource`; each verb names its own operand.
```
ntn api <PATH>... Call the public Notion API (beta)
ntn auth token Print the current authentication token
ntn datasources query <ID_OR_URL>
ntn datasources resolve <ID>
ntn pages get <PAGE_ID> Retrieve a page as Markdown
ntn pages create Create a page from Markdown content
ntn pages edit <PAGE_ID> Edit a page's content from Markdown
ntn pages trash <PAGE_ID> Trash a page
ntn whoami Show the authenticated Notion user
```
There is no `ntn blocks`, `ntn comments` or `ntn search`. Those are
reached through `ntn api` with the REST API's own paths, exactly as
upstream reaches them. Upstream's interactive and deploy verbs (`login`,
`logout`, `update`, `workers`, `notion-as-code`, `doctor`, `files`) are
out of scope for a virtualized CLI.
### Pages
```bash
ntn pages get --page a1b2c3d4-...
ntn pages create --json '{"parent":{"page_id":"a1b2c3d4"},"properties":{"title":[{"text":{"content":"Title"}}]}}'
ntn pages edit --page a1b2c3d4-... --json '{"properties":{"title":[{"text":{"content":"Renamed"}}]}}'
ntn pages trash --page a1b2c3d4-...
```
| Verb | Flags | Writes |
| -------- | ----------------- | ------ |
| `get` | `--page` | no |
| `create` | `--json` | yes |
| `edit` | `--page --json` | yes |
| `trash` | `--page` | yes |
`create` requires a `parent` key in the body; `edit` sends the body as
a `PATCH /pages` request; `trash` sets `in_trash`.
### Blocks and comments
Page bodies are **Markdown**, not property JSON. `create` takes the body
on `--content` or from stdin, and the first heading becomes the title.
```bash
ntn blocks append --block a1b2c3d4-... --json '{"children":[{"type":"paragraph","paragraph":{"rich_text":[{"text":{"content":"Hello"}}]}}]}'
ntn comments create --json '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"Comment"}}]}'
ntn pages get a1b2c3d4-...
ntn pages get a1b2c3d4-... --json
ntn pages create --content '# Title' --parent page:a1b2c3d4-...
echo '# Title' | ntn pages create --parent data-source:e5f6a7b8-...
ntn pages edit a1b2c3d4-... --content '# Replaced body'
ntn pages trash a1b2c3d4-... --yes
```
### Data sources and search
| Verb | Operand | Options | Writes |
| -------- | ----------- | -------------------------------------- | ------ |
| `get` | `<PAGE_ID>` | `--json` | no |
| `create` | none | `--content` `--parent` `--json` | yes |
| `edit` | `<PAGE_ID>` | `--content` `--json` | yes |
| `trash` | `<PAGE_ID>` | `--yes` | yes |
`--parent` takes `page:<id>`, `database:<id>` or `data-source:<id>`.
`edit` replaces the page body wholesale. `trash` refuses without `--yes`
unless a prompt can be answered, and sets `in_trash`.
To set a row's **property values** rather than its body, use `ntn api`:
```bash
ntn datasources query --datasource e5f6a7b8-... --json '{"filter":{"property":"Status","select":{"equals":"Done"}}}'
ntn search --query "Roadmap" --limit 5
ntn api v1/pages/<row-id> -X PATCH \
-d '{"properties":{"Stage":{"select":{"name":"Draft"}}}}'
```
`datasources query` returns the database's row pages; `search` returns
compact rows (`title`, `page_id`, `url`, `last_edited`, `parent_type`).
Use the `<page-id>` / `<database-id>` from a mounted path segment as
the `--page`, `--block`, or `--datasource` value.
### Data sources
Since `2025-09-03` a database is a container of *data sources*, and the
rows and the column schema live on the data source. `resolve` turns a
database id into its data source ids; `query` accepts either in the same
slot.
```bash
ntn datasources resolve e5f6a7b8-...
ntn datasources query d5000000-... --limit 10
ntn datasources query d5000000-... -s 'Priority desc'
ntn datasources query d5000000-... --filter '{"property":"Stage","select":{"equals":"Done"}}'
ntn datasources query d5000000-... --json
```
`query` prints one tab-separated line per row: the page id, then the
property values in alphabetical order by column name. The columns are the
ones the returned rows actually carry, so a result set that does not cover
the whole schema prints narrower.
### Raw API
`ntn api` reaches every route that has no typed verb, including the only
delete verb the public API has (`DELETE /v1/blocks/{id}`, which trashes a
block, a page, or a database row).
```bash
ntn api v1/users/me
ntn api v1/search -d '{"query":"Roadmap"}'
ntn api v1/search query=Roadmap
ntn api v1/blocks/<page-id>/children page_size==10
ntn api v1/blocks/<block-id> -X DELETE
ntn api v1/comments -d '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"hi"}}]}'
printf '{"query":"Roadmap"}' | ntn api v1/search
```
The body comes from exactly one source: stdin, `--data`/`-d`, or inline
`path=value` / `path:=json` inputs. Naming two is an error. `name==value`
stays a query parameter whatever the method is, and `Header:Value` sets a
header. Any body source makes the call a POST unless `-X`/`--method` says
otherwise; `GET`, `POST`, `PATCH`, `PUT` and `DELETE` are accepted.
Use the `<page-id>` / `<database-id>` / `<data-source-id>` from a mounted
path segment as the operand.
+115 -69
View File
@@ -39,9 +39,11 @@ ws = Workspace({"/notion": resource}, mode=MountMode.WRITE)
databases/
<database-title>__<database-id>/
database.json
<row-page-title>__<page-id>/
page.json
...
<data-source-name>__<data-source-id>/
data_source.json
<row-page-title>__<page-id>/
page.json
...
```
Example:
@@ -49,97 +51,133 @@ Example:
```text
/notion/
pages/
Project-Roadmap__a1b2c3d4/
Project_Roadmap__a1b2c3d4/
page.json
Q1-Goals__e5f6g7h8/
Q1_Goals__e5f6g7h8/
page.json
Q2-Goals__i9j0k1l2/
Q2_Goals__i9j0k1l2/
page.json
Meeting-Notes__m3n4o5p6/
Meeting_Notes__m3n4o5p6/
page.json
databases/
Tasks__4a3b21915e77/
database.json
Write-proposal__62212c5affe6/
page.json
Build-dashboards__f988c5a145ef/
page.json
Roadmap__2c4d6a7f5bf3/
database.json
Tasks__d5000000-2222-3333-4444-555566667777/
data_source.json
Write_proposal__62212c5affe6/
page.json
Build_dashboards__f988c5a145ef/
page.json
```
The `pages/` hierarchy mirrors Notion's standalone page tree. The
`databases/` hierarchy lists databases shared with the integration and
then the row pages returned by querying each database. Each database
directory contains `database.json` with the database metadata and its
typed property schema (the columns), but not the rows themselves; the
rows are the row-page directories alongside it, so `ls` the database
directory to enumerate them. Each page directory contains a `page.json`
file with the page metadata and content. Child pages appear as nested
directories.
The `pages/` hierarchy mirrors Notion's standalone page tree. Each page
directory contains a `page.json` with the page metadata and content, and
child pages appear as nested directories.
`page.json` carries the page metadata (`page_id`, `title`, `url`,
timestamps, `parent_type`/`parent_id`, `created_by`/`last_edited_by`),
a `markdown` field with the page body rendered as Markdown, and the raw
`blocks`. Blocks with children embed them recursively under a
`children` key, and the Markdown renders nested blocks with
indentation.
The `databases/` hierarchy is one level deeper than the page tree,
because the `2025-09-03` API generation split a database into a
container plus one or more **data sources**. The column schema and the
rows both live on the data source, so a row page sits at depth 4 under
`databases/`, not 3. The name stutters for a single-source database
because Notion names the auto-created data source after its database;
that disappears the moment a database holds two.
`database.json` carries the database metadata (`database_id`, `title`,
`url`, timestamps, `parent`, `archived`, `is_inline`) and `properties`,
the database's typed column schema. It does not embed the rows; read a
row's `page.json` for its content, since a database row is itself a page
with `parent_type` of `database_id`.
`ls` a data source directory to enumerate its row pages.
### database.json
The database's identity plus its typed column schema (Notion's own
property objects), with no rows inline. `ls` the database directory to
enumerate row pages:
The container's identity. It carries `database_id`, `title`, `url`,
timestamps, `parent`, `archived`, `is_inline`, and the `data_sources`
stubs that name the directories beneath it. It **does not carry
`properties`**: at this API version the column schema lives on the data
source, and `GET /v1/databases/{id}` no longer answers with one.
```json
{
"database_id": "2c4d6a7f-5bf3-8036-bed2-d1c95826f76b",
"title": "Item",
"url": "https://www.notion.so/2c4d6a7f5bf38036bed2d1c95826f76b",
"created_time": "2025-12-09T23:36:00.000Z",
"last_edited_time": "2026-04-15T12:30:00.000Z",
"database_id": "eeee1111-2222-3333-4444-555566667777",
"title": "Tasks",
"url": "https://www.notion.so/eeee1111222233334444555566667777",
"created_time": "2026-01-01T00:00:00.000Z",
"last_edited_time": "2026-01-02T00:00:00.000Z",
"parent": { "type": "workspace", "workspace": true },
"archived": false,
"is_inline": false,
"data_sources": [
{ "id": "d5000000-2222-3333-4444-555566667777", "name": "Tasks" }
]
}
```
### data_source.json
The typed column schema (Notion's own property objects), with no rows
inline:
```json
{
"data_source_id": "d5000000-2222-3333-4444-555566667777",
"database_id": "eeee1111-2222-3333-4444-555566667777",
"title": "Tasks",
"created_time": "2026-01-01T00:00:00.000Z",
"last_edited_time": "2026-01-02T00:00:00.000Z",
"database_parent": { "type": "workspace", "workspace": true },
"archived": false,
"properties": {
"Name": { "id": "title", "type": "title", "title": {} },
"Number": { "id": "e%5BNQ", "type": "number", "number": { "format": "number" } },
"Date": { "id": "%3BgQq", "type": "date", "date": {} }
"Priority": { "id": "pri", "type": "number", "number": { "format": "number" } },
"Due": { "id": "du", "type": "date", "date": {} }
}
}
```
To create a row, `ntn pages create` with a `database_id` parent and a
`properties` object whose keys match this schema.
A data source id is **not** its database id. Turn one into the other
with `ntn datasources resolve <database-id>`.
### page.json
A page (including a database row). Notion blocks render to `markdown`
and stay available raw under `blocks`:
A page, including a database row. Notion blocks render to `markdown` and
stay available raw under `blocks`; a row's cell values are under
`properties`, as Notion's own property objects, answering to the schema
in the `data_source.json` one level up:
```json
{
"page_id": "2c4d6a7f-5bf3-8022-826b-ed4700254ed2",
"title": "item_0",
"url": "https://www.notion.so/item_0-2c4d6a7f5bf38022826bed4700254ed2",
"created_time": "2025-12-09T23:36:00.000Z",
"last_edited_time": "2025-12-09T23:44:00.000Z",
"parent_type": "database_id",
"parent_id": "2c4d6a7f-5bf3-8036-bed2-d1c95826f76b",
"page_id": "ffff1111-2222-3333-4444-555566667777",
"title": "Write spec",
"url": "https://www.notion.so/ffff1111222233334444555566667777",
"created_time": "2026-01-01T00:00:00.000Z",
"last_edited_time": "2026-01-02T00:00:00.000Z",
"parent_type": "data_source_id",
"parent_id": "d5000000-2222-3333-4444-555566667777",
"archived": false,
"created_by": "faa6ebe0-0686-466d-96b3-2480ddda715b",
"last_edited_by": "faa6ebe0-0686-466d-96b3-2480ddda715b",
"properties": {
"Name": {
"id": "title",
"type": "title",
"title": [{ "type": "text", "plain_text": "Write spec" }]
},
"Priority": { "id": "pri", "type": "number", "number": 2 },
"Done": { "id": "dn", "type": "checkbox", "checkbox": true }
},
"markdown": "",
"blocks": []
}
```
So a row's cells are readable with the ordinary tools:
```bash
jq '.properties.Priority.number' "$ROW/page.json"
jq -r '.properties | to_entries[] | "\(.key)\t\(.value.type)"' "$ROW/page.json"
```
Blocks with children embed them recursively under a `children` key, and
the Markdown renders nested blocks with indentation. A standalone page's
`properties` holds only its title, which is what the API returns for
one.
## Cache
Uses `IndexCacheStore` for page metadata. No separate content
@@ -170,13 +208,24 @@ async def main():
r = await ws.execute("ls /notion/pages/")
print(await r.stdout_str())
# List shared databases and rows in a database
# List shared databases, then a database's data sources, then its rows
r = await ws.execute("ls /notion/databases/")
print(await r.stdout_str())
r = await ws.execute("tree -L 3 /notion/databases/")
print(await r.stdout_str())
# Read a page
r = await ws.execute(
'cat "/notion/pages/Project-Roadmap__a1b2c3d4/page.json"'
'cat "/notion/pages/Project_Roadmap__a1b2c3d4/page.json"'
)
print(await r.stdout_str())
# Read one row's cells
r = await ws.execute(
'jq ".properties.Priority.number" '
'"/notion/databases/Tasks__4a3b21915e77'
'/Tasks__d5000000-2222-3333-4444-555566667777'
'/Write_proposal__62212c5affe6/page.json"'
)
print(await r.stdout_str())
@@ -189,22 +238,18 @@ async def main():
print(await r.stdout_str())
# Search pages with the Notion search API
r = await ws.execute('ntn search --query "Roadmap" --limit 5')
r = await ws.execute('ntn api v1/search -d \'{"query":"Roadmap"}\'')
print(await r.stdout_str())
# Create a new page
# Create a new page from Markdown
r = await ws.execute(
'ntn pages create --json \'{"parent":{"page_id":"a1b2c3d4"},'
'"properties":{"title":[{"text":{"content":"New Page"}}]}}\''
"ntn pages create --content '# New Page' --parent page:a1b2c3d4"
)
print(await r.stdout_str())
# Append content to an existing page
# Replace an existing page's body
r = await ws.execute(
'ntn blocks append --block a1b2c3d4'
' --json \'{"children":[{"type":"paragraph","paragraph":'
'{"rich_text":[{"text":{"content":"Appended paragraph"}}]}}]}\''
)
"ntn pages edit a1b2c3d4 --content '# Replaced body'")
print(await r.stdout_str())
@@ -228,7 +273,8 @@ Standard commands available on the mounted Notion tree:
| `find` | Recursive search with `-name`, `-maxdepth` |
| `tree` | Directory tree view |
Acting on Notion (creating and editing pages, appending blocks,
comments, search) goes through the [ntn CLI](/python/cli/ntn) when
installed. Use the `<page-id>`/`<database-id>` from a path segment as
the `--page`, `--block`, or `--datasource` value.
Acting on Notion (creating, editing and trashing pages, querying data
sources, and every route that has no typed verb) goes through the
[ntn CLI](/python/cli/ntn) when installed. Ids are positional: use the
`<page-id>` / `<database-id>` / `<data-source-id>` from a path segment as
the operand.
+94 -33
View File
@@ -5,7 +5,7 @@ icon: terminal
---
Notion API client following the official Notion CLI grammar. Install it on the workspace and the whole tree is
discoverable with `ntn --help`.
discoverable with `ntn --help` or `man ntn`.
## Install
@@ -23,46 +23,107 @@ install rides the `clis:` section; see the [CLI overview](/typescript/cli/index)
## Verbs
The grammar follows the official
[Notion CLI](https://developers.notion.com/cli) (`ntn pages
get/create/edit/trash`, `ntn datasources query`); `blocks`, `comments`
and `search` are mirage extensions spelled with the REST API's nouns.
JSON bodies are the raw Notion API resources.
The grammar matches the official
[Notion CLI](https://developers.notion.com/cli) verb for verb, and every
case is gated against the real `ntn` binary in CI, so what is written
here is what the program does.
**Ids are positional, not flags.** There is no `--page`, `--block` or
`--datasource`; each verb names its own operand.
```
ntn api <PATH>... Call the public Notion API (beta)
ntn auth token Print the current authentication token
ntn datasources query <ID_OR_URL>
ntn datasources resolve <ID>
ntn pages get <PAGE_ID> Retrieve a page as Markdown
ntn pages create Create a page from Markdown content
ntn pages edit <PAGE_ID> Edit a page's content from Markdown
ntn pages trash <PAGE_ID> Trash a page
ntn whoami Show the authenticated Notion user
```
There is no `ntn blocks`, `ntn comments` or `ntn search`. Those are
reached through `ntn api` with the REST API's own paths, exactly as
upstream reaches them. Upstream's interactive and deploy verbs (`login`,
`logout`, `update`, `workers`, `notion-as-code`, `doctor`, `files`) are
out of scope for a virtualized CLI.
### Pages
```bash
ntn pages get --page a1b2c3d4-...
ntn pages create --json '{"parent":{"page_id":"a1b2c3d4"},"properties":{"title":[{"text":{"content":"Title"}}]}}'
ntn pages edit --page a1b2c3d4-... --json '{"properties":{"title":[{"text":{"content":"Renamed"}}]}}'
ntn pages trash --page a1b2c3d4-...
```
| Verb | Flags | Writes |
| -------- | ----------------- | ------ |
| `get` | `--page` | no |
| `create` | `--json` | yes |
| `edit` | `--page --json` | yes |
| `trash` | `--page` | yes |
`create` requires a `parent` key in the body; `edit` sends the body as
a `PATCH /pages` request; `trash` sets `in_trash`.
### Blocks and comments
Page bodies are **Markdown**, not property JSON. `create` takes the body
on `--content` or from stdin, and the first heading becomes the title.
```bash
ntn blocks append --block a1b2c3d4-... --json '{"children":[{"type":"paragraph","paragraph":{"rich_text":[{"text":{"content":"Hello"}}]}}]}'
ntn comments create --json '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"Comment"}}]}'
ntn pages get a1b2c3d4-...
ntn pages get a1b2c3d4-... --json
ntn pages create --content '# Title' --parent page:a1b2c3d4-...
echo '# Title' | ntn pages create --parent data-source:e5f6a7b8-...
ntn pages edit a1b2c3d4-... --content '# Replaced body'
ntn pages trash a1b2c3d4-... --yes
```
### Data sources and search
| Verb | Operand | Options | Writes |
| -------- | ----------- | -------------------------------------- | ------ |
| `get` | `<PAGE_ID>` | `--json` | no |
| `create` | none | `--content` `--parent` `--json` | yes |
| `edit` | `<PAGE_ID>` | `--content` `--json` | yes |
| `trash` | `<PAGE_ID>` | `--yes` | yes |
`--parent` takes `page:<id>`, `database:<id>` or `data-source:<id>`.
`edit` replaces the page body wholesale. `trash` refuses without `--yes`
unless a prompt can be answered, and sets `in_trash`.
To set a row's **property values** rather than its body, use `ntn api`:
```bash
ntn datasources query --datasource e5f6a7b8-... --json '{"filter":{"property":"Status","select":{"equals":"Done"}}}'
ntn search --query "Roadmap" --limit 5
ntn api v1/pages/<row-id> -X PATCH \
-d '{"properties":{"Stage":{"select":{"name":"Draft"}}}}'
```
`datasources query` returns the database's row pages; `search` returns
compact rows (`title`, `page_id`, `url`, `last_edited`, `parent_type`).
Use the `<page-id>` / `<database-id>` from a mounted path segment as
the `--page`, `--block`, or `--datasource` value.
### Data sources
Since `2025-09-03` a database is a container of *data sources*, and the
rows and the column schema live on the data source. `resolve` turns a
database id into its data source ids; `query` accepts either in the same
slot.
```bash
ntn datasources resolve e5f6a7b8-...
ntn datasources query d5000000-... --limit 10
ntn datasources query d5000000-... -s 'Priority desc'
ntn datasources query d5000000-... --filter '{"property":"Stage","select":{"equals":"Done"}}'
ntn datasources query d5000000-... --json
```
`query` prints one tab-separated line per row: the page id, then the
property values in alphabetical order by column name. The columns are the
ones the returned rows actually carry, so a result set that does not cover
the whole schema prints narrower.
### Raw API
`ntn api` reaches every route that has no typed verb, including the only
delete verb the public API has (`DELETE /v1/blocks/{id}`, which trashes a
block, a page, or a database row).
```bash
ntn api v1/users/me
ntn api v1/search -d '{"query":"Roadmap"}'
ntn api v1/search query=Roadmap
ntn api v1/blocks/<page-id>/children page_size==10
ntn api v1/blocks/<block-id> -X DELETE
ntn api v1/comments -d '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"hi"}}]}'
printf '{"query":"Roadmap"}' | ntn api v1/search
```
The body comes from exactly one source: stdin, `--data`/`-d`, or inline
`path=value` / `path:=json` inputs. Naming two is an error. `name==value`
stays a query parameter whatever the method is, and `Header:Value` sets a
header. Any body source makes the call a POST unless `-X`/`--method` says
otherwise; `GET`, `POST`, `PATCH`, `PUT` and `DELETE` are accepted.
Use the `<page-id>` / `<database-id>` / `<data-source-id>` from a mounted
path segment as the operand.
+26 -19
View File
@@ -54,9 +54,9 @@ await ws.execute('ls /notion/')
## Acting on Notion
Acting on Notion (creating and editing pages, appending blocks,
comments, search) goes through the [ntn CLI](/typescript/cli/ntn)
when installed.
Acting on Notion (creating, editing and trashing pages, querying data
sources, and every route that has no typed verb) goes through the
[ntn CLI](/typescript/cli/ntn) when installed.
## Layout
@@ -71,33 +71,40 @@ when installed.
databases/
<database-title>__<database-id>/
database.json
<row-page-title>__<page-id>/
page.json
...
<data-source-name>__<data-source-id>/
data_source.json
<row-page-title>__<page-id>/
page.json
...
```
`database.json` is the database's identity plus its typed column schema,
with no rows inline; `ls` the database directory to enumerate row pages.
Since the `2025-09-03` API generation a database is a container of **data
sources**, and both the column schema and the rows live on the data
source, so a row page sits one level deeper than the page tree.
`database.json` is the container's identity plus the `data_sources` stubs
that name the directories beneath it, and carries **no** `properties`.
The shape is identical to the Python connector:
```json
{
"database_id": "2c4d6a7f-5bf3-8036-bed2-d1c95826f76b",
"title": "Item",
"url": "https://www.notion.so/2c4d6a7f5bf38036bed2d1c95826f76b",
"created_time": "2025-12-09T23:36:00.000Z",
"last_edited_time": "2026-04-15T12:30:00.000Z",
"database_id": "eeee1111-2222-3333-4444-555566667777",
"title": "Tasks",
"url": "https://www.notion.so/eeee1111222233334444555566667777",
"created_time": "2026-01-01T00:00:00.000Z",
"last_edited_time": "2026-01-02T00:00:00.000Z",
"parent": { "type": "workspace", "workspace": true },
"archived": false,
"is_inline": false,
"properties": {
"Name": { "id": "title", "type": "title", "title": {} },
"Number": { "id": "e%5BNQ", "type": "number", "number": { "format": "number" } },
"Date": { "id": "%3BgQq", "type": "date", "date": {} }
}
"data_sources": [
{ "id": "d5000000-2222-3333-4444-555566667777", "name": "Tasks" }
]
}
```
`data_source.json` holds the typed column schema under `properties`; `ls`
the data source directory to enumerate row pages.
A database row is itself a page: its `page.json` reports `parent_type` of
`database_id`. For full `page.json` field details and supported edits see
`data_source_id`, and its cell values ride in the same file under
`properties`. For full `page.json` field details and supported edits see
the [Python Notion docs](/python/resource/notion).
+33 -13
View File
@@ -48,6 +48,15 @@ async def first_entry(ws: Workspace, path: str) -> str:
return os.path.basename(out.splitlines()[0].rstrip("/"))
async def pick_child(ws: Workspace, path: str, skip: str) -> str:
result = await ws.execute(f"ls {path}/")
for line in (await result.stdout_str()).strip().splitlines():
name = os.path.basename(line.rstrip("/"))
if name != skip:
return name
return ""
async def explore_pages(ws: Workspace) -> None:
print("\n########## PAGES ##########\n")
await run(ws, "ls /notion/pages/")
@@ -103,40 +112,51 @@ async def explore_databases(ws: Workspace) -> None:
await run(ws, f"cat {base}/database.json", limit=1500)
await run(ws, f'jq ".database_id" {base}/database.json')
await run(ws, f'jq ".title" {base}/database.json')
await run(ws, f'jq ".properties | keys" {base}/database.json')
# The container carries data source stubs, not a column schema: since
# 2025-09-03 `properties` lives on the data source one level down.
await run(ws, f'jq ".data_sources" {base}/database.json')
await run(ws, f"wc -l {base}/database.json")
await run(ws, f"head -n 8 {base}/database.json")
await run(ws, f"tail -n 5 {base}/database.json")
await run(ws, f"basename {base}/database.json")
await run(ws, f"dirname {base}/database.json")
await run(ws, f"tree -L 1 {base}/")
await run(ws, f"tree -L 2 {base}/")
await run(ws, f'find {base}/ -name "database.json"')
await run(ws, f"echo {base}/*")
row = ""
result = await ws.execute(f"ls {base}/")
for line in (await result.stdout_str()).strip().splitlines():
name = os.path.basename(line.rstrip("/"))
if name != "database.json":
row = name
break
if not row:
print("Database has no row pages\n")
source = await pick_child(ws, base, "database.json")
if not source:
print("Database has no data sources\n")
return
row_base = f"{base}/{row}"
source_base = f"{base}/{source}"
print(f"--- data source: {source} ---\n")
await run(ws, f"ls {source_base}/")
await run(ws, f"cat {source_base}/data_source.json", limit=1500)
await run(ws, f'jq ".properties | keys" {source_base}/data_source.json')
row = await pick_child(ws, source_base, "data_source.json")
if not row:
print("Data source has no row pages\n")
return
row_base = f"{source_base}/{row}"
print(f"--- row page: {row} ---\n")
await run(ws, f"ls {row_base}/")
await run(ws, f"stat {row_base}/page.json")
await run(ws, f"cat {row_base}/page.json", limit=1200)
await run(ws, f'jq ".parent_type" {row_base}/page.json')
await run(ws, f'jq ".parent_id" {row_base}/page.json')
# A row's cells ride in the file, as Notion's own property objects,
# answering to the schema in the data_source.json above.
await run(ws, f'jq ".properties | keys" {row_base}/page.json')
async def explore_cross_cutting(ws: Workspace) -> None:
print("\n########## CROSS-CUTTING ##########\n")
await run(ws, "ls /notion/")
await run(ws, "tree -L 2 /notion/")
await run(ws, "ntn search --query a", limit=800)
# There is no `ntn search`: /search is reached through `ntn api`,
# exactly as the official CLI reaches it.
await run(ws, 'ntn api v1/search -d \'{"query":"a"}\'', limit=800)
await run(ws, 'grep -rl "page_id" /notion/pages/', limit=800)
await run(ws, 'rg -c "title" /notion/databases/', limit=800)
+34 -15
View File
@@ -45,6 +45,15 @@ async function firstEntry(ws: Workspace, path: string): Promise<string> {
return basename(out.split('\n')[0]!.replace(/\/$/, ''))
}
async function pickChild(ws: Workspace, path: string, skip: string): Promise<string> {
const listing = (await ws.execute(`ls "${path}/"`)).stdoutText.trim().split('\n')
for (const line of listing) {
const name = basename(line.replace(/\/$/, ''))
if (name !== skip && name !== '') return name
}
return ''
}
async function explorePages(ws: Workspace): Promise<void> {
console.log('\n########## PAGES ##########\n')
await run(ws, 'ls /notion/pages/')
@@ -101,43 +110,53 @@ async function exploreDatabases(ws: Workspace): Promise<void> {
await run(ws, `cat "${base}/database.json"`)
await run(ws, `jq ".database_id" "${base}/database.json"`)
await run(ws, `jq ".title" "${base}/database.json"`)
await run(ws, `jq ".properties | keys" "${base}/database.json"`)
// The container carries data source stubs, not a column schema: since
// 2025-09-03 `properties` lives on the data source one level down.
await run(ws, `jq ".data_sources" "${base}/database.json"`)
await run(ws, `wc -l "${base}/database.json"`)
await run(ws, `head -n 8 "${base}/database.json"`)
await run(ws, `tail -n 5 "${base}/database.json"`)
await run(ws, `basename "${base}/database.json"`)
await run(ws, `dirname "${base}/database.json"`)
await run(ws, `tree -L 1 "${base}/"`)
await run(ws, `tree -L 2 "${base}/"`)
await run(ws, `find "${base}/" -name "database.json"`)
await run(ws, `echo "${base}/"*`)
const listing = (await ws.execute(`ls "${base}/"`)).stdoutText.trim().split('\n')
let row = ''
for (const line of listing) {
const name = basename(line.replace(/\/$/, ''))
if (name !== 'database.json' && name !== '') {
row = name
break
}
}
if (row === '') {
console.log('Database has no row pages\n')
const source = await pickChild(ws, base, 'database.json')
if (source === '') {
console.log('Database has no data sources\n')
return
}
const rowBase = `${base}/${row}`
const sourceBase = `${base}/${source}`
console.log(`--- data source: ${source} ---\n`)
await run(ws, `ls "${sourceBase}/"`)
await run(ws, `cat "${sourceBase}/data_source.json"`)
await run(ws, `jq ".properties | keys" "${sourceBase}/data_source.json"`)
const row = await pickChild(ws, sourceBase, 'data_source.json')
if (row === '') {
console.log('Data source has no row pages\n')
return
}
const rowBase = `${sourceBase}/${row}`
console.log(`--- row page: ${row} ---\n`)
await run(ws, `ls "${rowBase}/"`)
await run(ws, `stat "${rowBase}/page.json"`)
await run(ws, `cat "${rowBase}/page.json"`, 1200)
await run(ws, `jq ".parent_type" "${rowBase}/page.json"`)
await run(ws, `jq ".parent_id" "${rowBase}/page.json"`)
// A row's cells ride in the file, as Notion's own property objects,
// answering to the schema in the data_source.json above.
await run(ws, `jq ".properties | keys" "${rowBase}/page.json"`)
}
async function exploreCrossCutting(ws: Workspace): Promise<void> {
console.log('\n########## CROSS-CUTTING ##########\n')
await run(ws, 'ls /notion/')
await run(ws, 'tree -L 2 /notion/')
await run(ws, 'ntn search --query a', 800)
// There is no `ntn search`: /search is reached through `ntn api`,
// exactly as the official CLI reaches it.
await run(ws, `ntn api v1/search -d '{"query":"a"}'`, 800)
await run(ws, 'grep -rl "page_id" /notion/pages/', 800)
await run(ws, 'rg -c "title" /notion/databases/', 800)
}
+52
View File
@@ -675,6 +675,58 @@
"stdout": "new\n",
"stderr": ""
}
},
{
"id": "ntn_archived_aliases_in_trash",
"seq": 566052,
"targets": [
"cli-ntn"
],
"command": "ntn api v1/pages/ffff2222-3333-4444-5555-666677778888 -X PATCH -d '{\"archived\":true}' | jq -r '.archived, .in_trash'",
"expect": {
"exit": 0,
"stdout": "true\ntrue\n",
"stderr": ""
}
},
{
"id": "ntn_archived_row_leaves_query",
"seq": 566053,
"targets": [
"cli-ntn"
],
"command": "ntn datasources query d5000000-2222-3333-4444-555566667777 | cut -f5",
"expect": {
"exit": 0,
"stdout": "Write spec\nRow page\n",
"stderr": ""
}
},
{
"id": "ntn_delete_block_trashes_a_row",
"seq": 566054,
"targets": [
"cli-ntn"
],
"command": "ntn api v1/blocks/ffff1111-2222-3333-4444-555566667777 -X DELETE | jq -r '.object, .in_trash'",
"expect": {
"exit": 0,
"stdout": "block\ntrue\n",
"stderr": ""
}
},
{
"id": "ntn_deleted_row_leaves_query",
"seq": 566055,
"targets": [
"cli-ntn"
],
"command": "ntn datasources query d5000000-2222-3333-4444-555566667777",
"expect": {
"exit": 0,
"stdout": "a0000000-0000-4000-8000-000000000002\tRow page\n",
"stderr": ""
}
}
]
}
+6 -3
View File
@@ -81,6 +81,12 @@ model Pin {
// a NotionBlock row of type child_page carrying the *same* id. Creating a page
// under a page_id parent must write both, or readdir and block-children
// disagree about the tree.
//
// Trash is ONE bit, stored as `inTrash`. Upstream's `archived` is a deprecated
// alias for it ("This is an alias for in_trash and always returns the same
// value"), so it is a spelling on the wire, not a column: two columns let the
// fake answer archived=true with in_trash=false, which real Notion cannot do,
// and made every filter have to remember to name both.
model NotionPage {
id String
workspaceId String
@@ -90,7 +96,6 @@ model NotionPage {
propertiesJson String
iconJson String?
coverJson String?
archived Boolean @default(false)
inTrash Boolean @default(false)
createdTime String
lastEditedTime String
@@ -113,7 +118,6 @@ model NotionDatabase {
descriptionJson String?
propertiesJson String
isInline Boolean @default(false)
archived Boolean @default(false)
inTrash Boolean @default(false)
createdTime String
lastEditedTime String
@@ -134,7 +138,6 @@ model NotionBlock {
type String
payloadJson String
hasChildren Boolean @default(false)
archived Boolean @default(false)
inTrash Boolean @default(false)
createdTime String
lastEditedTime String
+8 -8
View File
@@ -74,7 +74,7 @@
"command": "cat /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json",
"expect": {
"exit": 0,
"stdout": "{\n \"page_id\": \"aaaa1111-2222-3333-4444-555566667777\",\n \"title\": \"Project Roadmap\",\n \"url\": \"https://notion.example/aaaa1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"workspace\",\n \"parent_id\": \"\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"markdown\": \"# Roadmap\\n\\nShip the **beta** soon\\n\\n- phase one\\n\\n - phase one detail\\n\\n```python\\nprint(1)\\n```\\n\",\n \"blocks\": [\n {\n \"object\": \"block\",\n \"id\": \"b-a1\",\n \"type\": \"heading_1\",\n \"has_children\": false,\n \"heading_1\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Roadmap\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Roadmap\"\n }\n }\n ]\n }\n },\n {\n \"object\": \"block\",\n \"id\": \"b-a2\",\n \"type\": \"paragraph\",\n \"has_children\": false,\n \"paragraph\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Ship the \",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Ship the \"\n }\n },\n {\n \"type\": \"text\",\n \"plain_text\": \"beta\",\n \"annotations\": {\n \"bold\": true\n },\n \"text\": {\n \"content\": \"beta\"\n }\n },\n {\n \"type\": \"text\",\n \"plain_text\": \" soon\",\n \"annotations\": {},\n \"text\": {\n \"content\": \" soon\"\n }\n }\n ]\n }\n },\n {\n \"object\": \"block\",\n \"id\": \"dddd2222-3333-4444-5555-666677778888\",\n \"type\": \"bulleted_list_item\",\n \"has_children\": true,\n \"bulleted_list_item\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"phase one\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"phase one\"\n }\n }\n ]\n },\n \"children\": [\n {\n \"object\": \"block\",\n \"id\": \"b-d1\",\n \"type\": \"bulleted_list_item\",\n \"has_children\": false,\n \"bulleted_list_item\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"phase one detail\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"phase one detail\"\n }\n }\n ]\n }\n }\n ]\n },\n {\n \"object\": \"block\",\n \"id\": \"b-a4\",\n \"type\": \"code\",\n \"has_children\": false,\n \"code\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"print(1)\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"print(1)\"\n }\n }\n ],\n \"language\": \"python\"\n }\n }\n ]\n}",
"stdout": "{\n \"page_id\": \"aaaa1111-2222-3333-4444-555566667777\",\n \"title\": \"Project Roadmap\",\n \"url\": \"https://notion.example/aaaa1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"workspace\",\n \"parent_id\": \"\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"properties\": {\n \"title\": {\n \"id\": \"title\",\n \"type\": \"title\",\n \"title\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Project Roadmap\",\n \"text\": {\n \"content\": \"Project Roadmap\"\n }\n }\n ]\n }\n },\n \"markdown\": \"# Roadmap\\n\\nShip the **beta** soon\\n\\n- phase one\\n\\n - phase one detail\\n\\n```python\\nprint(1)\\n```\\n\",\n \"blocks\": [\n {\n \"object\": \"block\",\n \"id\": \"b-a1\",\n \"type\": \"heading_1\",\n \"has_children\": false,\n \"heading_1\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Roadmap\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Roadmap\"\n }\n }\n ]\n }\n },\n {\n \"object\": \"block\",\n \"id\": \"b-a2\",\n \"type\": \"paragraph\",\n \"has_children\": false,\n \"paragraph\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Ship the \",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Ship the \"\n }\n },\n {\n \"type\": \"text\",\n \"plain_text\": \"beta\",\n \"annotations\": {\n \"bold\": true\n },\n \"text\": {\n \"content\": \"beta\"\n }\n },\n {\n \"type\": \"text\",\n \"plain_text\": \" soon\",\n \"annotations\": {},\n \"text\": {\n \"content\": \" soon\"\n }\n }\n ]\n }\n },\n {\n \"object\": \"block\",\n \"id\": \"dddd2222-3333-4444-5555-666677778888\",\n \"type\": \"bulleted_list_item\",\n \"has_children\": true,\n \"bulleted_list_item\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"phase one\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"phase one\"\n }\n }\n ]\n },\n \"children\": [\n {\n \"object\": \"block\",\n \"id\": \"b-d1\",\n \"type\": \"bulleted_list_item\",\n \"has_children\": false,\n \"bulleted_list_item\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"phase one detail\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"phase one detail\"\n }\n }\n ]\n }\n }\n ]\n },\n {\n \"object\": \"block\",\n \"id\": \"b-a4\",\n \"type\": \"code\",\n \"has_children\": false,\n \"code\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"print(1)\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"print(1)\"\n }\n }\n ],\n \"language\": \"python\"\n }\n }\n ]\n}",
"stderr": ""
}
},
@@ -87,7 +87,7 @@
"command": "cat /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777/page.json",
"expect": {
"exit": 0,
"stdout": "{\n \"page_id\": \"cccc1111-2222-3333-4444-555566667777\",\n \"title\": \"Q1 Goals\",\n \"url\": \"https://notion.example/cccc1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"page_id\",\n \"parent_id\": \"aaaa1111-2222-3333-4444-555566667777\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"markdown\": \"Q1 contents\\n\",\n \"blocks\": [\n {\n \"object\": \"block\",\n \"id\": \"b-c1\",\n \"type\": \"paragraph\",\n \"has_children\": false,\n \"paragraph\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Q1 contents\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Q1 contents\"\n }\n }\n ]\n }\n }\n ]\n}",
"stdout": "{\n \"page_id\": \"cccc1111-2222-3333-4444-555566667777\",\n \"title\": \"Q1 Goals\",\n \"url\": \"https://notion.example/cccc1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"page_id\",\n \"parent_id\": \"aaaa1111-2222-3333-4444-555566667777\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"properties\": {\n \"title\": {\n \"id\": \"title\",\n \"type\": \"title\",\n \"title\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Q1 Goals\",\n \"text\": {\n \"content\": \"Q1 Goals\"\n }\n }\n ]\n }\n },\n \"markdown\": \"Q1 contents\\n\",\n \"blocks\": [\n {\n \"object\": \"block\",\n \"id\": \"b-c1\",\n \"type\": \"paragraph\",\n \"has_children\": false,\n \"paragraph\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Q1 contents\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Q1 contents\"\n }\n }\n ]\n }\n }\n ]\n}",
"stderr": ""
}
},
@@ -139,7 +139,7 @@
"command": "wc -l /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json /notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json",
"expect": {
"exit": 0,
"stdout": "125 /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json\n 51 /notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json\n176 total\n",
"stdout": "140 /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json\n 66 /notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json\n206 total\n",
"stderr": ""
}
},
@@ -152,7 +152,7 @@
"command": "stat /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json",
"expect": {
"exit": 0,
"stdout": "name=page.json size=2979 modified=None type=json\n",
"stdout": "name=page.json size=3254 modified=None type=json\n",
"stderr": ""
}
},
@@ -217,7 +217,7 @@
"command": "grep -n alpha /notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json",
"expect": {
"exit": 0,
"stdout": "12: \"markdown\": \"alpha beta gamma\\n\\n- [x] done item\\n\",\n23: \"plain_text\": \"alpha beta gamma\",\n26: \"content\": \"alpha beta gamma\"\n",
"stdout": "27: \"markdown\": \"alpha beta gamma\\n\\n- [x] done item\\n\",\n38: \"plain_text\": \"alpha beta gamma\",\n41: \"content\": \"alpha beta gamma\"\n",
"stderr": ""
}
},
@@ -347,7 +347,7 @@
"command": "cat /notion/databases/Tasks__eeee1111-2222-3333-4444-555566667777/Tasks__d5000000-2222-3333-4444-555566667777/Write_spec__ffff1111-2222-3333-4444-555566667777/page.json",
"expect": {
"exit": 0,
"stdout": "{\n \"page_id\": \"ffff1111-2222-3333-4444-555566667777\",\n \"title\": \"Write spec\",\n \"url\": \"https://notion.example/ffff1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"data_source_id\",\n \"parent_id\": \"d5000000-2222-3333-4444-555566667777\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"markdown\": \"\",\n \"blocks\": []\n}",
"stdout": "{\n \"page_id\": \"ffff1111-2222-3333-4444-555566667777\",\n \"title\": \"Write spec\",\n \"url\": \"https://notion.example/ffff1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"data_source_id\",\n \"parent_id\": \"d5000000-2222-3333-4444-555566667777\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"properties\": {\n \"Name\": {\n \"id\": \"title\",\n \"type\": \"title\",\n \"title\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Write spec\",\n \"text\": {\n \"content\": \"Write spec\"\n }\n }\n ]\n },\n \"Priority\": {\n \"id\": \"pri\",\n \"type\": \"number\",\n \"number\": 2\n },\n \"Done\": {\n \"id\": \"dn\",\n \"type\": \"checkbox\",\n \"checkbox\": true\n },\n \"Stage\": {\n \"id\": \"st\",\n \"type\": \"select\",\n \"select\": {\n \"id\": \"s2\",\n \"name\": \"Review\",\n \"color\": \"blue\"\n }\n },\n \"Tags\": {\n \"id\": \"tg\",\n \"type\": \"multi_select\",\n \"multi_select\": [\n {\n \"id\": \"t1\",\n \"name\": \"infra\",\n \"color\": \"green\"\n },\n {\n \"id\": \"t2\",\n \"name\": \"docs\",\n \"color\": \"yellow\"\n }\n ]\n },\n \"Due\": {\n \"id\": \"du\",\n \"type\": \"date\",\n \"date\": {\n \"start\": \"2026-02-01\",\n \"end\": null,\n \"time_zone\": null\n }\n },\n \"Link\": {\n \"id\": \"lk\",\n \"type\": \"url\",\n \"url\": \"https://example.com/spec\"\n },\n \"Notes\": {\n \"id\": \"nt\",\n \"type\": \"rich_text\",\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"needs review\",\n \"text\": {\n \"content\": \"needs review\"\n }\n }\n ]\n }\n },\n \"markdown\": \"\",\n \"blocks\": []\n}",
"stderr": ""
}
},
@@ -360,7 +360,7 @@
"command": "du /notion/pages/",
"expect": {
"exit": 0,
"stdout": "1211\t/notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888\n826\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777\n3805\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777\n5016\t/notion/pages\n",
"stdout": "1466\t/notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888\n1087\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777\n4341\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777\n5807\t/notion/pages\n",
"stderr": ""
}
},
@@ -373,7 +373,7 @@
"command": "du /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/",
"expect": {
"exit": 0,
"stdout": "826\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777\n3805\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777\n",
"stdout": "1087\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777\n4341\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777\n",
"stderr": ""
}
},
+1 -1
View File
@@ -1493,7 +1493,7 @@
"command": "f=/notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json; echo \"cold=$(stat -c %s \"$f\")\"; echo \"read=$(cat \"$f\" | wc -c)\"; echo \"warm=$(stat -c %s \"$f\")\"",
"expect": {
"exit": 0,
"stdout": "cold=0\nread=1211\nwarm=1211\n",
"stdout": "cold=0\nread=1466\nwarm=1466\n",
"stderr": ""
},
"clear_cache": true
+81 -16
View File
@@ -76,7 +76,12 @@ const DIR_A = `${MOUNT}/pages/Project_Roadmap__${PAGE_A}`
const DIR_B = `${MOUNT}/pages/Notes__${PAGE_B}`
const DIR_C = `${DIR_A}/Q1_Goals__${PAGE_C}`
const DB_DIR = `${MOUNT}/databases/Tasks__${DB_TASKS}`
const ROW_1_DIR = `${DB_DIR}/Write_spec__${ROW_1}`
// Since 2025-09-03 the rows live under the data source, not the database, so a
// row sits one level deeper than it used to. These paths feed the MCP/REST
// parity battery, where a stale one costs nothing visible: both arms answer
// the same error and the case passes while asserting nothing.
const DS_DIR = `${DB_DIR}/Tasks__${dataSourceIdOf(DB_TASKS)}`
const ROW_1_DIR = `${DS_DIR}/Write_spec__${ROW_1}`
type Json = Record<string, unknown>
@@ -119,6 +124,7 @@ interface FixtureDatabase {
last_edited_by?: string
url?: string
archived?: boolean
in_trash?: boolean
}
interface FixtureBlock {
id: string
@@ -156,7 +162,6 @@ interface PageRow {
propertiesJson: string
iconJson: string | null
coverJson: string | null
archived: boolean
inTrash: boolean
createdTime: string
lastEditedTime: string
@@ -173,7 +178,6 @@ interface DatabaseRow {
descriptionJson: string | null
propertiesJson: string
isInline: boolean
archived: boolean
inTrash: boolean
createdTime: string
lastEditedTime: string
@@ -188,7 +192,6 @@ interface BlockRow {
type: string
payloadJson: string
hasChildren: boolean
archived: boolean
inTrash: boolean
createdTime: string
lastEditedTime: string
@@ -462,7 +465,7 @@ async function seed(db: PrismaClient, fx: Fixture, workspaceId: string): Promise
descriptionJson: d.description !== undefined ? JSON.stringify(d.description) : null,
propertiesJson: JSON.stringify(d.properties),
isInline: d.is_inline ?? false,
archived: d.archived ?? false,
inTrash: d.in_trash ?? d.archived ?? false,
createdTime: d.created_time ?? fx.defaults.created_time,
lastEditedTime: d.last_edited_time ?? fx.defaults.last_edited_time,
createdBy: d.created_by ?? fx.defaults.created_by,
@@ -485,8 +488,7 @@ async function seed(db: PrismaClient, fx: Fixture, workspaceId: string): Promise
propertiesJson: JSON.stringify(properties),
iconJson: p.icon !== undefined ? JSON.stringify(p.icon) : null,
coverJson: p.cover !== undefined ? JSON.stringify(p.cover) : null,
archived: p.archived ?? false,
inTrash: p.in_trash ?? false,
inTrash: p.in_trash ?? p.archived ?? false,
createdTime: p.created_time ?? fx.defaults.created_time,
lastEditedTime: p.last_edited_time ?? fx.defaults.last_edited_time,
createdBy: p.created_by ?? fx.defaults.created_by,
@@ -535,6 +537,9 @@ async function seed(db: PrismaClient, fx: Fixture, workspaceId: string): Promise
}
}
// `archived` is upstream's deprecated alias for `in_trash` and, in its own
// words, "always returns the same value". So both names are read off the one
// stored bit here rather than from two columns that can disagree.
function pageJson(row: PageRow): Json {
const out: Json = {
object: 'page',
@@ -544,7 +549,7 @@ function pageJson(row: PageRow): Json {
created_by: { object: 'user', id: row.createdBy },
last_edited_by: { object: 'user', id: row.lastEditedBy },
parent: pageParentJson(row.parentType, row.parentId),
archived: row.archived,
archived: row.inTrash,
in_trash: row.inTrash,
url: row.url,
properties: JSON.parse(row.propertiesJson) as Json,
@@ -592,7 +597,7 @@ function dataSourceJson(row: DatabaseRow): Json {
last_edited_time: row.lastEditedTime,
parent: { type: 'database_id', database_id: row.id },
database_parent: parentJson(row.parentType, row.parentId),
archived: row.archived,
archived: row.inTrash,
in_trash: row.inTrash,
title: JSON.parse(row.titleJson) as unknown[],
description: [],
@@ -612,7 +617,7 @@ function databaseJson(row: DatabaseRow): Json {
created_time: row.createdTime,
last_edited_time: row.lastEditedTime,
parent: parentJson(row.parentType, row.parentId),
archived: row.archived,
archived: row.inTrash,
in_trash: row.inTrash,
is_inline: row.isInline,
url: row.url,
@@ -820,7 +825,7 @@ async function childrenOf(
parentId: string,
): Promise<BlockRow[]> {
return (await db.notionBlock.findMany({
where: { workspaceId, parentId, archived: false },
where: { workspaceId, parentId, inTrash: false },
orderBy: { position: 'asc' },
})) as BlockRow[]
}
@@ -834,14 +839,14 @@ async function searchResults(db: PrismaClient, workspaceId: string, args: Json):
// battery's client and the official CLI can share one server.
if (filter.value === 'database' || filter.value === 'data_source') {
const rows = (await db.notionDatabase.findMany({
where: { workspaceId, archived: false, inTrash: false },
where: { workspaceId, inTrash: false },
orderBy: [{ position: 'asc' }, { id: 'asc' }],
})) as DatabaseRow[]
const kept = rows.filter((r) => matches(r.titleText))
return filter.value === 'data_source' ? kept.map(dataSourceJson) : kept.map(databaseJson)
}
const rows = (await db.notionPage.findMany({
where: { workspaceId, archived: false, inTrash: false },
where: { workspaceId, inTrash: false },
orderBy: [{ position: 'asc' }, { id: 'asc' }],
})) as PageRow[]
return rows.filter((r) => matches(r.titleText)).map(pageJson)
@@ -1157,6 +1162,49 @@ async function createComment(
return { status: 200, json: commentJson(row) }
}
// A child page is one object in two tables (see the schema's NotionPage note),
// so trashing it has to move both rows: the NotionPage row is what /search and
// a database query read, the NotionBlock row is what the parent's children
// listing reads, and setting only one leaves the page gone from half the
// surfaces and present in the other half.
async function setTrashed(
db: PrismaClient,
workspaceId: string,
id: string,
trashed: boolean,
): Promise<void> {
const where = { workspaceId_id: { workspaceId, id } }
if ((await db.notionPage.findFirst({ where: { workspaceId, id } })) !== null) {
await db.notionPage.update({ where, data: { inTrash: trashed } })
}
if ((await db.notionBlock.findFirst({ where: { workspaceId, id } })) !== null) {
await db.notionBlock.update({ where, data: { inTrash: trashed } })
}
}
// DELETE /v1/blocks/{id} is the only delete verb the public API has, and the
// only one the MCP tool surface exposes (API-delete-a-block), so without it an
// MCP client cannot remove anything. Upstream: "Sets a Block object, including
// page blocks, to in_trash: true", which covers database rows, so this resolves
// a block id first and falls back to a page of the same id.
async function deleteBlock(db: PrismaClient, workspaceId: string, id: string): Promise<Reply> {
const block = (await db.notionBlock.findFirst({
where: { workspaceId, id },
})) as BlockRow | null
const page = (await db.notionPage.findFirst({
where: { workspaceId, id },
})) as PageRow | null
if (block === null && page === null) return notFound('block', id)
await setTrashed(db, workspaceId, id, true)
// A page that owns no block row (a top-level page, or a database row) still
// answers as a block, which is what "including page blocks" means.
const body =
block === null
? { object: 'block', id, type: 'child_page', has_children: false, child_page: { title: (page as PageRow).titleText } }
: blockJson(block)
return { status: 200, json: { ...body, archived: true, in_trash: true } }
}
async function updatePage(
db: PrismaClient,
workspaceId: string,
@@ -1166,8 +1214,10 @@ async function updatePage(
const row = (await db.notionPage.findFirst({ where: { workspaceId, id } })) as PageRow | null
if (row === null) return notFound('page', id)
const data: Record<string, unknown> = {}
if (typeof body.archived === 'boolean') data.archived = body.archived
if (typeof body.in_trash === 'boolean') data.inTrash = body.in_trash
// Two spellings of one bit, so `ntn pages trash` (in_trash) and an API or
// MCP client (archived) reach the same state rather than half of it.
const trash = typeof body.in_trash === 'boolean' ? body.in_trash : body.archived
if (typeof trash === 'boolean') await setTrashed(db, workspaceId, id, trash)
if (body.properties !== undefined) {
const owner =
row.parentType === 'database_id' && row.parentId !== null
@@ -1361,6 +1411,10 @@ async function handle(
return appendChildren(db, ws, fx, parts[2] ?? '', body)
}
if (method === 'DELETE' && parts.length === 3 && parts[1] === 'blocks') {
return deleteBlock(db, ws, parts[2] ?? '')
}
if (method === 'POST' && parts.length === 2 && parts[1] === 'comments') {
return createComment(db, ws, fx, body)
}
@@ -1515,6 +1569,14 @@ async function toolPayload(db: PrismaClient, name: string, args: Json): Promise<
const size = intOr(args.page_size, MAX_PAGE_SIZE)
return pageOf(rows.map(blockJson), cursorOf(args.start_cursor), size)
}
// The one delete verb the tool surface has. It mutates, so it takes the same
// per-workspace queue every REST mutation takes rather than a second rule.
if (name === 'API-delete-a-block') {
const id = String(args.block_id)
const reply = await serialize(ws, () => deleteBlock(db, ws, id))
if (reply.status !== 200) throw new Error(`mock notion: unknown block ${id}`)
return reply.json
}
throw new Error(`mock notion: unsupported tool ${name}`)
}
@@ -1581,8 +1643,11 @@ export const CASES: ReadonlyArray<readonly [string, string]> = [
['ls_databases', `ls ${MOUNT}/databases/`],
['ls_database_dir', `ls ${DB_DIR}/`],
['cat_database_json', `cat ${DB_DIR}/database.json`],
['jq_db_props', `jq ".properties | keys" ${DB_DIR}/database.json`],
['ls_data_source_dir', `ls ${DS_DIR}/`],
['cat_data_source_json', `cat ${DS_DIR}/data_source.json`],
['jq_data_source_props', `jq ".properties | keys" ${DS_DIR}/data_source.json`],
['cat_row', `cat ${ROW_1_DIR}/page.json`],
['jq_row_cells', `jq ".properties.Priority.number" ${ROW_1_DIR}/page.json`],
['du_pages', `du ${MOUNT}/pages/`],
['du_page_a', `du ${DIR_A}/`],
]
@@ -22,8 +22,8 @@ from mirage.commands.cli.builtin.ntn.util import (compact_json, first_text,
from mirage.commands.cli.types import CLIInvocation
from mirage.commands.errors import UsageError
from mirage.commands.spec.types import FlagView
from mirage.core.notion._client import (notion_get, notion_patch, notion_post,
notion_put)
from mirage.core.notion._client import (notion_delete, notion_get,
notion_patch, notion_post, notion_put)
from mirage.core.notion.config import NotionConfig
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult, materialize
@@ -58,11 +58,16 @@ REFUSAL_EXIT = 5
# at all".
NotionCall = Callable[..., Awaitable[dict[str, Any]]]
# DELETE is here because `DELETE /v1/blocks/{id}` is the only delete verb the
# public API has, so without it the one way to remove anything is unreachable
# from this CLI. It takes no body, which is why it is reached through `-X`
# rather than by a body source inferring it.
METHODS: dict[str, NotionCall] = {
"GET": notion_get,
"POST": notion_post,
"PATCH": notion_patch,
"PUT": notion_put,
"DELETE": notion_delete,
}
@@ -144,12 +144,19 @@ async def query(
if fl.as_bool("json"):
return yield_bytes(pretty_json(result)), IOResult()
# Columns are the schema's property names in alphabetical order,
# which is what the upstream CLI prints whatever order the API
# happens to report the schema in.
columns = sorted((data_source.get("properties") or {}).keys())
# Columns are the property names the returned rows actually carry, in
# alphabetical order, not the data source's whole schema. Upstream
# derives them from the page objects it got back, so a result set that
# does not cover the schema prints narrower: a row created from Markdown
# alone holds only its title column, and on its own it prints as
# `<id>\t<title>` rather than as one title among seven blanks.
rows = result.get("results") or []
columns = sorted(
{name
for row in rows
for name in (row.get("properties") or {})})
lines: list[str] = []
for row in result.get("results") or []:
for row in rows:
props = row.get("properties") or {}
cells = [property_cell(props.get(name)) for name in columns]
lines.append("\t".join([str(row.get("id", "")), *cells]) + "\n")
+26
View File
@@ -166,6 +166,32 @@ async def notion_put(
return data
# DELETE carries no body at all, which is why it does not take one: the only
# route the public API exposes it on is /v1/blocks/{id}, whose whole payload is
# the id in the path.
async def notion_delete(
config: NotionConfig,
path: str,
body: JsonValue = None,
extra_headers: Mapping[str, str] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
url = f"{config.base_url}{path}"
headers = notion_headers(config, extra_headers)
async with aiohttp.ClientSession() as session:
async with session.delete(url, headers=headers, params=params) as resp:
data = await resp.json()
if resp.status >= 400:
message = data.get(
"message") or f"Notion API error: HTTP {resp.status}"
raise NotionAPIError(
message,
status=resp.status,
code=data.get("code"),
)
return data
async def paginate_list(
config: NotionConfig,
path: str,
+9
View File
@@ -30,6 +30,14 @@ def normalize_page(page: dict[str, Any],
b for b in blocks
if b.get("type") not in ("child_page", "child_database")
]
properties = page.get("properties", {})
if not isinstance(properties, dict):
properties = {}
# A database row's cells are its `properties`, and they are the reason
# the row exists, so they belong in the file rather than only in a
# `datasources query`. Kept as Notion's own property objects for the
# same reason `blocks` is: the schema they answer to is rendered one
# level up, in data_source.json's `properties`.
return {
"page_id": page.get("id", ""),
"title": extract_title(page),
@@ -41,6 +49,7 @@ def normalize_page(page: dict[str, Any],
"archived": page.get("archived", False),
"created_by": page.get("created_by", {}).get("id", ""),
"last_edited_by": page.get("last_edited_by", {}).get("id", ""),
"properties": properties,
"markdown": blocks_to_markdown(content_blocks),
"blocks": content_blocks,
}
+4
View File
@@ -59,7 +59,11 @@ def extract_data_source_title(data_source: dict[str, Any]) -> str:
def extract_title(page: dict[str, Any]) -> str:
props = page.get("properties", {})
if not isinstance(props, dict):
return ""
for prop in props.values():
if not isinstance(prop, dict):
continue
if prop.get("type") == "title":
title_items = prop.get("title", [])
return "".join(item.get("plain_text", "") for item in title_items)
@@ -297,3 +297,34 @@ async def test_body_infers_post_and_output_is_compact(monkeypatch):
# Compact and key-sorted, the upstream serializer for `ntn api`,
# with the trailing newline the real binary emits.
assert (await materialize(out)) == b'{"a":2,"b":1}\n'
@pytest.mark.asyncio
async def test_delete_is_a_reachable_method(monkeypatch):
# `DELETE /v1/blocks/{id}` is the only delete verb the public API has,
# and it is the one the MCP tool surface exposes as
# API-delete-a-block, so a table without it leaves an agent no way to
# remove anything. It reached the user as `unsupported method: DELETE`
# (exit 2) where the real binary trashes the block.
seen: dict[str, Any] = {}
async def fake_delete(
config: NotionConfig,
path: str,
body: JsonValue = None,
extra_headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None) -> dict[str, Any]:
seen["path"] = path
seen["body"] = body
return {"object": "block", "in_trash": True}
monkeypatch.setitem(METHODS, "DELETE", fake_delete)
out, io = await api(
CLIInvocation(CONFIG,
texts=("v1/blocks/abc-123", ),
flags={"method": "delete"}))
assert io.exit_code == 0
assert seen["path"] == "/blocks/abc-123"
# No body source on the line, so nothing is invented for one.
assert seen["body"] is None
assert (await materialize(out)) == b'{"in_trash":true,"object":"block"}\n'
@@ -278,6 +278,50 @@ async def test_datasources_query_sorts_columns_by_name(monkeypatch):
assert await _text(out) == "R1\tWrite spec\t2\n"
@pytest.mark.asyncio
async def test_datasources_query_takes_columns_from_the_rows(monkeypatch):
# Upstream derives the columns from the page objects it got back, not
# from the data source's schema, so a result set that does not cover
# the schema prints narrower. A row created from Markdown alone holds
# only its title column, and on its own it prints as `<id>\t<title>`
# rather than as one title among blanks.
async def fake_source(config, source_id):
return {
"id": source_id,
"properties": {
"Priority": {
"type": "number"
},
"Name": {
"type": "title"
},
},
}
async def fake_query(config, source_id, body):
return {
"results": [{
"id": "R2",
"properties": {
"Name": {
"type": "title",
"title": [{
"plain_text": "Row page"
}],
},
},
}],
"has_more":
False,
}
monkeypatch.setitem(query.__globals__, "get_data_source", fake_source)
monkeypatch.setitem(query.__globals__, "query_data_source_page",
fake_query)
out, _io = await query(CLIInvocation(CONFIG, texts=("S1", ), flags={}))
assert await _text(out) == "R2\tRow page\n"
@pytest.mark.asyncio
async def test_datasources_query_reports_the_next_cursor(monkeypatch):
+41
View File
@@ -116,6 +116,47 @@ class TestNormalizePage:
assert "Hello" in result["markdown"]
assert len(result["blocks"]) == 1
def test_a_row_carries_its_cells(self):
# A database row's cells are its `properties`, and they are the
# reason the row exists. Without them `cat` on a row answered with
# metadata and an empty markdown, and the row's actual data was
# reachable only through `ntn datasources query`.
page = {
"id": "row-1",
"parent": {
"type": "data_source_id",
"data_source_id": "ds-1",
},
"properties": {
"Name": {
"id": "title",
"type": "title",
"title": [{
"plain_text": "Write spec"
}],
},
"Priority": {
"id": "pri",
"type": "number",
"number": 2,
},
},
}
result = normalize_page(page, [])
assert result["properties"]["Priority"]["number"] == 2
assert result["parent_id"] == "ds-1"
# Kept as Notion's own property objects, not flattened, so the
# ids and types the schema is written in survive the render.
assert result["properties"]["Name"]["id"] == "title"
def test_a_page_without_properties_renders_an_empty_map(self):
result = normalize_page({"id": "p1"}, [])
assert result["properties"] == {}
def test_a_non_object_properties_is_not_forwarded(self):
result = normalize_page({"id": "p1", "properties": []}, [])
assert result["properties"] == {}
def test_to_json_bytes(self):
data = to_json_bytes({"key": "value"})
assert isinstance(data, bytes)
@@ -43,7 +43,11 @@ const INLINE_SOURCE = 'inline body inputs'
const BAD_BODY_EXIT = 1
const REFUSAL_EXIT = 5
const METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT'])
// DELETE is here because `DELETE /v1/blocks/{id}` is the only delete verb the
// public API has, so without it the one way to remove anything is unreachable
// from this CLI. It takes no body, which is why it is reached through `-X`
// rather than by a body source inferring it.
const METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE'])
// One inline input the CLI could not interpret, carrying the clause upstream
// puts after its fixed lead.
@@ -129,10 +129,18 @@ export async function query(inv: CLIInvocation): Promise<CommandFnResult> {
const result = await queryDataSourcePage(transport, strOf(dataSource, 'id'), body)
if (fl.asBool('json')) return [prettyJson(result), new IOResult()]
// Columns are the schema's property names in alphabetical order, which is
// what the upstream CLI prints whatever order the API reports the schema in.
const columns = Object.keys(asObject(dataSource.properties)).sort()
// Columns are the property names the returned rows actually carry, in
// alphabetical order, not the data source's whole schema. Upstream derives
// them from the page objects it got back, so a result set that does not
// cover the schema prints narrower: a row created from Markdown alone holds
// only its title column, and on its own it prints as `<id>\t<title>` rather
// than as one title among seven blanks.
const rows = Array.isArray(result.results) ? result.results : []
const named = new Set<string>()
for (const row of rows) {
for (const name of Object.keys(asObject(asObject(row).properties))) named.add(name)
}
const columns = [...named].sort()
let out = ''
for (const row of rows) {
const record = asObject(row)
@@ -224,6 +224,37 @@ describe('ntn verbs', () => {
RESPONSE = { id: 'P1' }
})
// Upstream derives the columns from the page objects it got back, not from
// the data source's schema, so a result set that does not cover the schema
// prints narrower. A row created from Markdown alone holds only its title
// column, and on its own it prints as `<id>\t<title>` rather than as one
// title among blanks.
it('datasources query takes its columns from the returned rows', async () => {
CALLS.length = 0
RESPONSE = {
id: 'S1',
properties: { Priority: { type: 'number' }, Name: { type: 'title' } },
results: [
{ id: 'R2', properties: { Name: { type: 'title', title: [{ plain_text: 'Row page' }] } } },
],
has_more: false,
}
const [out] = unwrap(await query(makeInv({}, ['S1'])))
expect(DEC.decode(out as Uint8Array)).toBe('R2\tRow page\n')
RESPONSE = { id: 'P1' }
})
// `DELETE /v1/blocks/{id}` is the only delete verb the public API has, and
// it is the one the MCP tool surface exposes as API-delete-a-block, so a
// method table without it leaves an agent no way to remove anything.
it('api can issue the one delete verb the API has', async () => {
REQUESTS.length = 0
const [out] = unwrap(await api(makeInv({ method: 'delete' }, ['v1/blocks/B1'])))
// No body source on the line, so nothing is invented for one.
expect(REQUESTS[0]).toEqual({ method: 'DELETE', path: '/blocks/B1' })
expect(DEC.decode(out as Uint8Array)).toBe('{"id":"P1"}\n')
})
it('api infers the method and strips the version prefix from the path', async () => {
REQUESTS.length = 0
await api(makeInv({}, ['v1/users/me']))
@@ -153,7 +153,7 @@ export interface HttpNotionTransportOptions {
}
export interface RestCall {
method: 'GET' | 'POST' | 'PATCH' | 'PUT'
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
path: string
query?: Record<string, unknown>
/**
@@ -252,7 +252,11 @@ export class HttpNotionTransport implements NotionTransport {
...(call.headers ?? {}),
},
}
if (call.method !== 'GET') init.body = JSON.stringify(call.body ?? {})
// GET and DELETE carry no body: the only route DELETE exists on is
// /v1/blocks/{id}, whose whole payload is the id in the path.
if (call.method !== 'GET' && call.method !== 'DELETE') {
init.body = JSON.stringify(call.body ?? {})
}
const res = await this.fetch(url, init)
const data = (await res.json()) as Record<string, unknown>
if (res.status >= 400) {
@@ -130,6 +130,9 @@ describe('normalizePage', () => {
archived: false,
created_by: 'user-1',
last_edited_by: 'user-2',
properties: {
Name: { id: 'title', type: 'title', title: [{ plain_text: 'Hello' }] },
},
markdown: 'Hi\n\n# Title\n',
blocks,
})
@@ -150,11 +153,32 @@ describe('normalizePage', () => {
archived: false,
created_by: '',
last_edited_by: '',
properties: {},
markdown: '',
blocks: [],
})
})
// A database row's cells are its `properties`, and they are the reason the
// row exists. Without them `cat` on a row answered with metadata and an
// empty markdown, and the row's actual data was reachable only through
// `ntn datasources query`.
it('carries the cells of a database row', () => {
const page = {
id: 'row-1',
parent: { type: 'data_source_id', data_source_id: 'ds-1' },
properties: {
Name: { id: 'title', type: 'title', title: [{ plain_text: 'Write spec' }] },
Priority: { id: 'pri', type: 'number', number: 2 },
},
}
const out = normalizePage(page, [])
expect(out.parent_id).toBe('ds-1')
// Kept as Notion's own property objects, not flattened, so the ids and
// types the schema is written in survive the render.
expect(out.properties).toEqual(page.properties)
})
it('drops child_page and child_database blocks from the body', () => {
const page = { id: '2c4e9c3a-1234-5678-90ab-cdef01234567' }
const keep = { object: 'block', id: 'b1', type: 'paragraph', paragraph: { rich_text: [] } }
@@ -133,6 +133,7 @@ export interface NormalizedPage {
archived: boolean
created_by: string
last_edited_by: string
properties: Json
markdown: string
blocks: Json[]
}
@@ -160,6 +161,11 @@ export interface NormalizedDataSource {
properties: Json
}
// A database row's cells are its `properties`, and they are the reason the row
// exists, so they belong in the file rather than only in a `datasources query`.
// Kept as Notion's own property objects for the same reason `blocks` is: the
// schema they answer to is rendered one level up, in data_source.json's
// `properties`.
export function normalizePage(page: Json, blocks: readonly Json[]): NormalizedPage {
const parent = asObject(page.parent)
const parentType = strOf(parent, 'type')
@@ -180,6 +186,7 @@ export function normalizePage(page: Json, blocks: readonly Json[]): NormalizedPa
archived: boolOf(page, 'archived'),
created_by: strOf(asObject(page.created_by), 'id'),
last_edited_by: strOf(asObject(page.last_edited_by), 'id'),
properties: asObject(page.properties),
markdown: blocksToMarkdown(contentBlocks),
blocks: contentBlocks,
}