Deep dive into how notioncli works under the hood. You don't need any of this to use the CLI — it just works. This is for contributors and the curious.
The 2025 API introduced a dual-ID system for databases:
| ID | What it's for | Example endpoint |
|---|---|---|
database_id |
Page creation (parent), databases.retrieve() |
pages.create({ parent: { database_id } }) |
data_source_id |
Querying, schema, property management | dataSources.query(), dataSources.retrieve(), dataSources.update() |
When you run notion init, both IDs are discovered and stored per alias. When you pass a raw UUID, notioncli resolves which ID to use depending on the operation.
databases.update() silently ignores property changes in the 2025 API. It returns 200 OK but drops all property modifications. You must use dataSources.update() for:
- Adding properties
- Removing properties
- Renaming properties
databases.update() still works for title changes only.
Similarly, databases.create() silently ignores non-title properties. notioncli handles this with a two-step approach:
- Create the database with title via
databases.create() - Add properties via
dataSources.update()
databases.retrieve(database_id) → NO properties field
dataSources.retrieve(data_source_id) → HAS properties field (this is the schema)
getDbSchema() in notioncli correctly uses dataSources.retrieve().
The dataSources namespace provides: retrieve, query, create, update, listTemplates.
bin/notion.js is a thin orchestrator (~28 lines). All logic lives in modules:
bin/notion.js — CLI entry point, registers commands
lib/context.js — Shared context factory (config, auth, Notion client, schema helpers)
lib/helpers.js — Re-exports all lib modules
lib/format.js — Output formatting (table, CSV, YAML, JSON), property building
lib/filters.js — Filter parsing, operator detection, compound filters
lib/markdown.js — Markdown ↔ Notion blocks, CSV parsing, inline formatting
lib/config.js — Config load/save, workspace resolution
lib/paginate.js — Cursor-based pagination
lib/retry.js — Exponential backoff with jitter for rate limits
commands/config.js — init, alias (add/remove/list/rename), workspace (add/list/use/remove)
commands/search.js — search
commands/query.js — query with filters, sorting, pagination
commands/crud.js — add, update, delete, get
commands/blocks.js — blocks, block-edit, block-delete, append
commands/database.js — dbs, db-create, db-update, templates
commands/users.js — users, user, me
commands/comments.js — comments, comment
commands/pages.js — relations, move, props
commands/import-export.js — import, export
commands/upload.js — file upload
Each command module exports register(program, ctx) where ctx is the shared context from createContext(program). The context provides config helpers, the lazy Notion client, schema resolution, and all formatting utilities.
Every command that targets a database goes through resolveDb(alias_or_id):
- Check aliases in config (scoped to active workspace)
- If UUID, return as-is (used as
data_source_id) - If neither, error with helpful suggestion
Commands that target a single page (update, delete, get, etc.) use resolvePageId():
- If input is a UUID → return directly
- If input is an alias → require
--filter, query the database, expect exactly 1 result - Zero matches → error with "No pages found"
- Multiple matches → error with "Multiple pages found, refine your filter"
Commander.js .allowUnknownOption() lets unknown flags pass through. extractDynamicProps() parses raw process.argv:
- Find flags starting with
--that aren't in the known flags list - Convert
--kebab-casetoTitle CaseviakebabToProperty() - Match against database schema (case-insensitive)
- Return as
Key=Valuepairs for property building
parseFilterOperator() splits filter strings by checking operators in order: >=, <=, !=, >, <, = (multi-char first to avoid false splits).
Relative dates (today, yesterday, tomorrow, last_week, next_week) are resolved to ISO date strings at parse time.
Multiple --filter flags combine with AND logic via buildCompoundFilter().
paginate() in lib/paginate.js is a generic cursor-based pagination helper. It wraps any Notion API call that returns { results, has_more, next_cursor } and accumulates all pages:
- Default behavior fetches all results (no limit)
--limit Ncaps results and emits a stderr warning if truncated- Used by: search, query, blocks, dbs, users, comments
withRetry() in lib/retry.js wraps API calls with exponential backoff + jitter on 429 responses. The Notion client is wrapped via wrapNotionClient() which uses a JS Proxy to transparently intercept all method calls — no code changes needed per-endpoint.
Default: 5 attempts, 1s base delay, 2x multiplier, ±25% jitter.
buildPropValue() in lib/format.js validates before hitting the API:
- Numbers: rejects NaN values
- Dates: validates against ISO 8601 (YYYY-MM-DD or full datetime) via regex + Date.parse
- URLs: requires
http://orhttps://prefix - Emails: requires
@character
Returns { error: "..." } objects; the caller prints a clear message and exits.
markdownToBlocks() parses: headings (h1-h3), bullet lists (with nested indentation via stack-based tracking), numbered lists, todo items, code blocks (fenced with language), blockquotes, dividers (---), and paragraphs with inline formatting (bold, italic, code, links).
blocksToMarkdown() reverses the process, preserving rich text annotations (bold → **, italic → *, code → backticks, strikethrough → ~~, links → [text](url)) via richTextToMarkdown().
parseCsv() uses character-by-character scanning to correctly handle quoted fields containing newlines, commas, and escaped quotes (""). Previously split on \n which broke multiline fields.
Config format supports named workspace profiles:
{
"activeWorkspace": "default",
"workspaces": {
"default": { "apiKey": "ntn_...", "aliases": { ... } },
"work": { "apiKey": "ntn_...", "aliases": { ... } }
}
}Old flat configs ({ apiKey, aliases }) are auto-migrated to { workspaces: { default: { apiKey, aliases } } } on first load.
213 tests across 27 suites, zero dependencies (node:test + node:assert):
test/unit.test.js— Pure function tests (no API calls). Covers: property formatting (38 types), filter building (26 operators), markdown parsing (10 block types + nested bullets), CSV parsing (multiline fields), inline formatting, pagination, retry logic, input validation, dynamic prop extraction.test/mock.test.js— Command logic with mocked Notion client. Config management, filter building, schema resolution.test/integration.test.js— Live API tests (requiresNOTION_API_KEY). Skipped in CI.
Run tests: npm test
@notionhq/clientv5.x (official Notion SDK)commanderfor CLI parsingnode:test+node:assertfor testing (zero test dependencies)
git clone https://github.com/JordanCoin/notioncli.git
cd notioncli
npm install
export NOTION_API_KEY=ntn_your_test_key
npm test
node bin/notion.js --help