ntion 0.2.0 → 0.4.0

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.
package/README.md CHANGED
@@ -35,6 +35,16 @@ A single "get all my tasks" workflow tells the whole story:
35
35
  2. **No schema bloat** — MCP's database fetch includes ~2 KB of SQLite DDL, ~800 B of XML boilerplate, and ~1.4 KB of base64 `collectionPropertyOption://` URLs that are never used for reads. ntion returns only actionable data.
36
36
  3. **Markdown-first** — Page content defaults to markdown, matching what agents actually consume. No manual format negotiation needed.
37
37
 
38
+ ### Operations the official MCP can't do
39
+
40
+ The official Notion MCP server has no block delete tool. ntion does:
41
+
42
+ ```bash
43
+ ntion blocks delete --block-ids <block_id>
44
+ ```
45
+
46
+ Delete one or many blocks in a single call — useful for cleaning up content, removing broken blocks, or precise page editing.
47
+
38
48
  ## Agent skill
39
49
 
40
50
  ntion ships with an [agent skill](https://docs.anthropic.com/en/docs/claude-code/skills) that teaches AI agents how to use the CLI. Install it with:
@@ -138,9 +148,11 @@ ntion pages unrelate --from-id <page_id> --property Project --to-id <page_id>
138
148
  ```bash
139
149
  # Read as markdown (default)
140
150
  ntion blocks get --id <page_or_block_id> --depth 1
151
+ ntion blocks get --id <page_or_block_id> --view full
152
+ # blocks get uses --view (markdown|compact|full)
141
153
 
142
154
  # Append markdown content
143
- ntion blocks append --id <page_or_block_id> --markdown "# Title\n\nHello"
155
+ ntion blocks append --id <page_or_block_id> --markdown $'# Title\n\nHello'
144
156
  ntion blocks append --id <page_or_block_id> --markdown-file ./notes.md
145
157
 
146
158
  # Surgical insertion
@@ -157,6 +169,10 @@ ntion blocks replace-range \
157
169
  --start-selector-json '{"where":{"text_contains":"Start"}}' \
158
170
  --end-selector-json '{"where":{"text_contains":"End"}}' \
159
171
  --markdown "Replacement content"
172
+
173
+ # Delete blocks (not available in the official Notion MCP)
174
+ ntion blocks delete --block-ids <block_id>
175
+ ntion blocks delete --block-ids <id1> <id2> <id3>
160
176
  ```
161
177
 
162
178
  ### Health check
@@ -183,7 +199,7 @@ Compact, deterministic, easy to parse — by humans or machines.
183
199
 
184
200
  - **Generic** — works with any Notion workspace, no hardcoded schema assumptions
185
201
  - **Compact** — deterministic JSON envelopes, minimal bytes per response
186
- - **Safe** — automatic idempotency for all mutations, built-in conflict detection
202
+ - **Safe** — automatic idempotency for all mutations, built-in conflict detection and verify-first recovery
187
203
  - **Fast** — zero native dependencies, internal schema caching, batch operations
188
204
  - **Agent-friendly** — designed for AI agents that pay per token
189
205
 
@@ -6,5 +6,9 @@ export interface AuditEvent {
6
6
  target_ids?: string[];
7
7
  ok: boolean;
8
8
  timestamp: string;
9
+ recovery_attempted?: boolean;
10
+ recovery_succeeded?: boolean;
11
+ idempotency_persist_degraded?: boolean;
12
+ outcome_uncertain?: boolean;
9
13
  }
10
14
  export declare function appendAuditLog(event: AuditEvent): Promise<void>;
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
- import { Command } from "commander";
3
+ import { Command, Option } from "commander";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { stdin as input, stdout as output } from "node:process";
6
6
  import { executeMutationWithIdempotency } from "./commands/mutation.js";
@@ -10,7 +10,7 @@ import { getConfigPath } from "./config/paths.js";
10
10
  import { buildInitialAuthConfig, loadConfigOrNull, saveConfig } from "./config/store.js";
11
11
  import { CliError } from "./errors/cli-error.js";
12
12
  import { markdownToBlocks } from "./notion/markdown.js";
13
- import { appendBlocks, archivePage, createPage, createPagesBulk, getBlocks, getDataSource, getDataSourceSchema, getPage, insertBlocks, listDataSources, queryDataSourcePages, replaceBlockRange, searchWorkspace, selectBlocks, setRelation, unarchivePage, updatePage, } from "./notion/repository.js";
13
+ import { appendBlocks, archivePage, areBlocksDeleted, createPage, createPagesBulk, deleteBlocks, getBlocks, getDataSource, getDataSourceSchema, getPage, insertBlocks, listDataSources, queryDataSourcePages, replaceBlockRange, searchWorkspace, selectBlocks, setRelation, unarchivePage, updatePage, } from "./notion/repository.js";
14
14
  import { parseJsonOption } from "./utils/json.js";
15
15
  const ROOT_HELP_EPILOG = [
16
16
  "",
@@ -59,9 +59,15 @@ const BLOCKS_APPEND_HELP_EPILOG = [
59
59
  " Provide exactly one of --blocks-json, --markdown, or --markdown-file.",
60
60
  "",
61
61
  "Examples:",
62
- " ntion blocks append --id <id> --markdown \"# Heading\\n\\nBody\"",
62
+ " ntion blocks append --id <id> --markdown $'# Heading\\n\\nBody'",
63
63
  " ntion blocks append --id <id> --markdown-file ./notes.md",
64
64
  ].join("\n");
65
+ const BLOCKS_GET_HELP_EPILOG = [
66
+ "",
67
+ "View vs Return-View:",
68
+ " blocks get uses --view <markdown|compact|full>.",
69
+ " mutation commands use --return-view <compact|full> for page/data outputs.",
70
+ ].join("\n");
65
71
  const BLOCKS_INSERT_HELP_EPILOG = [
66
72
  "",
67
73
  "Positioning:",
@@ -104,7 +110,7 @@ function resolveBlockReadFormat(input, fallback) {
104
110
  if (value === "markdown" || value === "compact" || value === "full") {
105
111
  return value;
106
112
  }
107
- throw new CliError("invalid_input", "Block format must be markdown, compact, or full.");
113
+ throw new CliError("invalid_input", "Block view must be markdown, compact, or full.");
108
114
  }
109
115
  function parseSearchObject(raw) {
110
116
  if (!raw) {
@@ -188,11 +194,26 @@ async function resolveBlocksInput(args) {
188
194
  return requireArrayJson(args.blocksJson, "blocks-json");
189
195
  }
190
196
  if (args.markdown) {
197
+ validateMarkdownInput(args.markdown);
191
198
  return markdownToBlocks(args.markdown);
192
199
  }
193
200
  const markdown = await readFile(String(args.markdownFile), "utf8");
194
201
  return markdownToBlocks(markdown);
195
202
  }
203
+ function validateMarkdownInput(markdown) {
204
+ if (markdown.includes("\\n") && !markdown.includes("\n")) {
205
+ throw new CliError("invalid_input", "--markdown appears to contain literal \\n escapes. Use real newline characters or --markdown-file.");
206
+ }
207
+ }
208
+ function relationIncludesTarget(page, propertyName, targetId) {
209
+ const properties = page.properties;
210
+ if (!properties || typeof properties !== "object") {
211
+ return false;
212
+ }
213
+ const relationValue = properties[propertyName];
214
+ return (Array.isArray(relationValue) &&
215
+ relationValue.some((value) => typeof value === "string" && value === targetId));
216
+ }
196
217
  function addCommonReadOptions(command) {
197
218
  return command
198
219
  .option("--view <compact|full>", "response view mode")
@@ -218,6 +239,7 @@ async function runInteractiveAuthSetup() {
218
239
  }
219
240
  const rl = createInterface({ input, output });
220
241
  try {
242
+ console.log("Create a token at https://www.notion.so/profile/integrations\n");
221
243
  const response = await rl.question("Paste your Notion integration token: ");
222
244
  const trimmed = response.trim();
223
245
  if (!trimmed) {
@@ -254,9 +276,11 @@ async function saveAuthConfigEnv(tokenEnv) {
254
276
  config_path: getConfigPath(),
255
277
  };
256
278
  }
279
+ const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf-8"));
257
280
  const program = new Command();
258
281
  program
259
282
  .name("ntion")
283
+ .version(packageJson.version, "-V, --version", "print version number")
260
284
  .description("Token-efficient, workspace-agnostic Notion CLI")
261
285
  .showHelpAfterError()
262
286
  .addHelpText("after", ROOT_HELP_EPILOG);
@@ -611,6 +635,16 @@ addCommonMutationOptions(pagesCommand.command("archive").description("Archive a
611
635
  view,
612
636
  fields,
613
637
  }),
638
+ recover: async () => {
639
+ const current = await getPage(notion, options.id, "full");
640
+ if (current.archived !== true) {
641
+ return null;
642
+ }
643
+ if (view === "full") {
644
+ return current;
645
+ }
646
+ return getPage(notion, options.id, view, fields);
647
+ },
614
648
  });
615
649
  return {
616
650
  data: {
@@ -644,6 +678,16 @@ addCommonMutationOptions(pagesCommand.command("unarchive").description("Unarchiv
644
678
  view,
645
679
  fields,
646
680
  }),
681
+ recover: async () => {
682
+ const current = await getPage(notion, options.id, "full");
683
+ if (current.archived !== false) {
684
+ return null;
685
+ }
686
+ if (view === "full") {
687
+ return current;
688
+ }
689
+ return getPage(notion, options.id, view, fields);
690
+ },
647
691
  });
648
692
  return {
649
693
  data: {
@@ -684,6 +728,16 @@ addCommonMutationOptions(pagesCommand.command("relate").description("Add a relat
684
728
  view,
685
729
  fields,
686
730
  }),
731
+ recover: async () => {
732
+ const current = await getPage(notion, options.fromId, "full");
733
+ if (!relationIncludesTarget(current, options.property, options.toId)) {
734
+ return null;
735
+ }
736
+ if (view === "full") {
737
+ return current;
738
+ }
739
+ return getPage(notion, options.fromId, view, fields);
740
+ },
687
741
  });
688
742
  return {
689
743
  data: {
@@ -724,6 +778,16 @@ addCommonMutationOptions(pagesCommand.command("unrelate").description("Remove a
724
778
  view,
725
779
  fields,
726
780
  }),
781
+ recover: async () => {
782
+ const current = await getPage(notion, options.fromId, "full");
783
+ if (relationIncludesTarget(current, options.property, options.toId)) {
784
+ return null;
785
+ }
786
+ if (view === "full") {
787
+ return current;
788
+ }
789
+ return getPage(notion, options.fromId, view, fields);
790
+ },
727
791
  });
728
792
  return {
729
793
  data: {
@@ -733,27 +797,32 @@ addCommonMutationOptions(pagesCommand.command("unrelate").description("Remove a
733
797
  });
734
798
  });
735
799
  const blocksCommand = program.command("blocks").description("Block operations");
736
- blocksCommand
800
+ const blocksGetCommand = blocksCommand
737
801
  .command("get")
738
802
  .description("Get blocks from a page or block")
739
803
  .requiredOption("--id <page_or_block_id>", "Notion page or block ID")
740
804
  .option("--max-blocks <n>", "Maximum block count")
741
805
  .option("--depth <n>", "Recursion depth", "1")
742
- .option("--format <markdown|compact|full>", "content format", "markdown")
806
+ .option("--view <markdown|compact|full>", "content view", "markdown")
807
+ .addOption(new Option("--format <markdown|compact|full>").hideHelp())
743
808
  .option("--pretty", "pretty-print JSON output")
744
809
  .option("--timeout-ms <n>", "request timeout in milliseconds")
745
810
  .action(async (options) => {
746
811
  await runAction(Boolean(options.pretty), async () => {
812
+ if (options.format) {
813
+ throw new CliError("invalid_input", "--format is no longer supported for blocks get. Use --view <markdown|compact|full>.");
814
+ }
747
815
  const { config, notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
748
816
  const maxBlocks = parsePositiveInt(options.maxBlocks, "max-blocks", config.defaults.max_blocks);
749
817
  const depth = parsePositiveInt(options.depth, "depth", 1);
750
- const format = resolveBlockReadFormat(options.format, "markdown");
751
- const blocks = await getBlocks(notion, options.id, maxBlocks, depth, format);
818
+ const view = resolveBlockReadFormat(options.view, "markdown");
819
+ const blocks = await getBlocks(notion, options.id, maxBlocks, depth, view);
752
820
  return {
753
821
  data: blocks,
754
822
  };
755
823
  });
756
824
  });
825
+ blocksGetCommand.addHelpText("after", BLOCKS_GET_HELP_EPILOG);
757
826
  const blocksAppendCommand = blocksCommand
758
827
  .command("append")
759
828
  .description("Append blocks to a page or block")
@@ -766,12 +835,12 @@ const blocksAppendCommand = blocksCommand
766
835
  .option("--timeout-ms <n>", "request timeout in milliseconds")
767
836
  .action(async (options) => {
768
837
  await runAction(Boolean(options.pretty), async (requestId) => {
769
- const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
770
838
  const blocks = await resolveBlocksInput({
771
839
  blocksJson: options.blocksJson,
772
840
  markdown: options.markdown,
773
841
  markdownFile: options.markdownFile,
774
842
  });
843
+ const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
775
844
  const result = await executeMutationWithIdempotency({
776
845
  commandName: options.dryRun ? "blocks.append.dry_run" : "blocks.append",
777
846
  requestId,
@@ -807,13 +876,13 @@ const blocksInsertCommand = blocksCommand
807
876
  .option("--timeout-ms <n>", "request timeout in milliseconds")
808
877
  .action(async (options) => {
809
878
  await runAction(Boolean(options.pretty), async (requestId) => {
810
- const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
811
879
  const blocks = await resolveBlocksInput({
812
880
  blocksJson: options.blocksJson,
813
881
  markdown: options.markdown,
814
882
  markdownFile: options.markdownFile,
815
883
  });
816
884
  const position = resolveInsertPosition(options.position, options.afterId);
885
+ const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
817
886
  const result = await executeMutationWithIdempotency({
818
887
  commandName: options.dryRun ? "blocks.insert.dry_run" : "blocks.insert",
819
888
  requestId,
@@ -878,7 +947,6 @@ const blocksReplaceRangeCommand = blocksCommand
878
947
  .option("--timeout-ms <n>", "request timeout in milliseconds")
879
948
  .action(async (options) => {
880
949
  await runAction(Boolean(options.pretty), async (requestId) => {
881
- const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
882
950
  const blocks = await resolveBlocksInput({
883
951
  blocksJson: options.blocksJson,
884
952
  markdown: options.markdown,
@@ -887,6 +955,7 @@ const blocksReplaceRangeCommand = blocksCommand
887
955
  const startSelector = requireSelectorJson(options.startSelectorJson, "start-selector-json");
888
956
  const endSelector = requireSelectorJson(options.endSelectorJson, "end-selector-json");
889
957
  const maxBlocks = parsePositiveInt(options.scanMaxBlocks, "scan-max-blocks", 5000);
958
+ const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
890
959
  const result = await executeMutationWithIdempotency({
891
960
  commandName: options.dryRun ? "blocks.replace_range.dry_run" : "blocks.replace_range",
892
961
  requestId,
@@ -918,6 +987,37 @@ const blocksReplaceRangeCommand = blocksCommand
918
987
  });
919
988
  });
920
989
  blocksReplaceRangeCommand.addHelpText("after", BLOCKS_REPLACE_RANGE_HELP_EPILOG);
990
+ blocksCommand
991
+ .command("delete")
992
+ .description("Delete one or more blocks by ID")
993
+ .requiredOption("--block-ids <ids...>", "Block IDs to delete")
994
+ .option("--pretty", "pretty-print JSON output")
995
+ .option("--timeout-ms <n>", "request timeout in milliseconds")
996
+ .action(async (options) => {
997
+ await runAction(Boolean(options.pretty), async (requestId) => {
998
+ const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
999
+ const result = await executeMutationWithIdempotency({
1000
+ commandName: "blocks.delete",
1001
+ requestId,
1002
+ requestShape: { block_ids: options.blockIds },
1003
+ targetIds: options.blockIds,
1004
+ run: () => deleteBlocks(notion, { blockIds: options.blockIds }),
1005
+ recover: async () => {
1006
+ const deleted = await areBlocksDeleted(notion, options.blockIds);
1007
+ if (!deleted) {
1008
+ return null;
1009
+ }
1010
+ return {
1011
+ deleted_count: options.blockIds.length,
1012
+ deleted_ids: options.blockIds,
1013
+ };
1014
+ },
1015
+ });
1016
+ return {
1017
+ data: result,
1018
+ };
1019
+ });
1020
+ });
921
1021
  program
922
1022
  .command("doctor")
923
1023
  .description("Validate config and auth quickly")
@@ -5,4 +5,6 @@ export declare function executeMutationWithIdempotency<T>(args: {
5
5
  entity?: string;
6
6
  targetIds?: string[];
7
7
  run: () => Promise<T>;
8
+ recover?: () => Promise<T | null>;
9
+ isAmbiguousError?: (error: unknown) => boolean;
8
10
  }): Promise<T>;
@@ -13,11 +13,42 @@ async function safeAppendAuditLog(event) {
13
13
  // Audit logging must not alter mutation outcomes.
14
14
  }
15
15
  }
16
+ function getStatus(error) {
17
+ if (!error || typeof error !== "object") {
18
+ return undefined;
19
+ }
20
+ const status = error.status;
21
+ return typeof status === "number" ? status : undefined;
22
+ }
23
+ function getCode(error) {
24
+ if (!error || typeof error !== "object") {
25
+ return undefined;
26
+ }
27
+ const code = error.code;
28
+ return typeof code === "string" ? code : undefined;
29
+ }
30
+ function isAmbiguousMutationError(error, override) {
31
+ if (override) {
32
+ return override(error);
33
+ }
34
+ if (error instanceof CliError && error.code === "retryable_upstream") {
35
+ return true;
36
+ }
37
+ const status = getStatus(error);
38
+ if (status === 429 || (typeof status === "number" && status >= 500)) {
39
+ return true;
40
+ }
41
+ return getCode(error) === "internal_error";
42
+ }
16
43
  export async function executeMutationWithIdempotency(args) {
17
44
  const store = new IdempotencyStore();
18
45
  const requestHash = hashObject(args.requestShape);
19
46
  const idempotencyKey = buildInternalIdempotencyKey(args.commandName, args.requestShape);
20
47
  let ownsReservation = false;
48
+ let recoveryAttempted = false;
49
+ let recoverySucceeded = false;
50
+ let idempotencyPersistDegraded = false;
51
+ let outcomeUncertain = false;
21
52
  try {
22
53
  let lookup = store.reserve(idempotencyKey, args.commandName, requestHash);
23
54
  while (lookup.kind === "pending") {
@@ -68,8 +99,45 @@ export async function executeMutationWithIdempotency(args) {
68
99
  return lookup.response;
69
100
  }
70
101
  ownsReservation = true;
71
- const response = await args.run();
72
- store.complete(idempotencyKey, args.commandName, requestHash, response);
102
+ let response;
103
+ try {
104
+ response = await args.run();
105
+ }
106
+ catch (error) {
107
+ if (!isAmbiguousMutationError(error, args.isAmbiguousError)) {
108
+ throw error;
109
+ }
110
+ recoveryAttempted = true;
111
+ if (args.recover) {
112
+ try {
113
+ const recovered = await args.recover();
114
+ if (recovered !== null) {
115
+ response = recovered;
116
+ recoverySucceeded = true;
117
+ }
118
+ else {
119
+ outcomeUncertain = true;
120
+ throw new CliError("retryable_upstream", "Mutation outcome could not be confirmed. Re-read before retrying.", { retryable: true });
121
+ }
122
+ }
123
+ catch {
124
+ outcomeUncertain = true;
125
+ throw new CliError("retryable_upstream", "Mutation outcome could not be confirmed. Re-read before retrying.", { retryable: true });
126
+ }
127
+ }
128
+ else {
129
+ outcomeUncertain = true;
130
+ throw new CliError("retryable_upstream", "Mutation outcome could not be confirmed. Re-read before retrying.", { retryable: true });
131
+ }
132
+ }
133
+ try {
134
+ store.complete(idempotencyKey, args.commandName, requestHash, response);
135
+ }
136
+ catch {
137
+ // Avoid surfacing internal idempotency persistence failures to callers
138
+ // after the write has already succeeded or been verified.
139
+ idempotencyPersistDegraded = true;
140
+ }
73
141
  await safeAppendAuditLog({
74
142
  command: args.commandName,
75
143
  entity: args.entity,
@@ -77,6 +145,10 @@ export async function executeMutationWithIdempotency(args) {
77
145
  idempotency_key: idempotencyKey,
78
146
  target_ids: args.targetIds,
79
147
  ok: true,
148
+ recovery_attempted: recoveryAttempted,
149
+ recovery_succeeded: recoverySucceeded,
150
+ idempotency_persist_degraded: idempotencyPersistDegraded,
151
+ outcome_uncertain: false,
80
152
  timestamp: new Date().toISOString(),
81
153
  });
82
154
  return response;
@@ -92,6 +164,10 @@ export async function executeMutationWithIdempotency(args) {
92
164
  idempotency_key: idempotencyKey,
93
165
  target_ids: args.targetIds,
94
166
  ok: false,
167
+ recovery_attempted: recoveryAttempted,
168
+ recovery_succeeded: recoverySucceeded,
169
+ idempotency_persist_degraded: idempotencyPersistDegraded,
170
+ outcome_uncertain: outcomeUncertain,
95
171
  timestamp: new Date().toISOString(),
96
172
  });
97
173
  throw error;
@@ -2,4 +2,5 @@ export declare function getConfigDir(): string;
2
2
  export declare function getConfigPath(): string;
3
3
  export declare function getAuditLogPath(): string;
4
4
  export declare function getIdempotencyStorePath(): string;
5
+ export declare function getIdempotencyLockPath(): string;
5
6
  export declare function ensureConfigDir(): string;
@@ -17,6 +17,9 @@ export function getAuditLogPath() {
17
17
  export function getIdempotencyStorePath() {
18
18
  return join(getConfigDir(), "idempotency.json");
19
19
  }
20
+ export function getIdempotencyLockPath() {
21
+ return join(getConfigDir(), "idempotency.lock");
22
+ }
20
23
  export function ensureConfigDir() {
21
24
  const configDir = getConfigDir();
22
25
  if (!existsSync(configDir)) {
@@ -22,13 +22,19 @@ export type IdempotencyReservation = {
22
22
  };
23
23
  export declare class IdempotencyStore {
24
24
  private readonly filePath;
25
+ private readonly lockPath;
25
26
  constructor(filePath?: string);
26
27
  close(): void;
27
28
  lookup(idempotencyKey: string, commandName: string, inputHash: string): IdempotencyLookup;
28
29
  reserve(idempotencyKey: string, commandName: string, inputHash: string): IdempotencyReservation;
29
30
  complete(idempotencyKey: string, commandName: string, inputHash: string, response: unknown): void;
30
31
  release(idempotencyKey: string, commandName: string, inputHash: string): void;
31
- private loadData;
32
- private saveData;
32
+ private withLock;
33
+ private acquireLock;
34
+ private releaseLock;
35
+ private tryClearStaleLock;
36
+ private sleepMs;
37
+ private loadDataUnlocked;
38
+ private saveDataUnlocked;
33
39
  }
34
40
  export declare function buildInternalIdempotencyKey(commandName: string, requestShape: unknown): string;
@@ -1,87 +1,172 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { ensureConfigDir, getIdempotencyStorePath } from "../config/paths.js";
1
+ import { closeSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { ensureConfigDir, getIdempotencyLockPath, getIdempotencyStorePath } from "../config/paths.js";
3
3
  import { CliError } from "../errors/cli-error.js";
4
4
  import { hashObject } from "../utils/json.js";
5
5
  const PRUNE_TTL_MS = 180_000;
6
+ const LOCK_STALE_MS = 30_000;
7
+ const LOCK_WAIT_TIMEOUT_MS = 35_000;
8
+ const LOCK_POLL_INTERVAL_MS = 30;
9
+ const SLEEP_ARRAY = new Int32Array(new SharedArrayBuffer(4));
6
10
  const PENDING_RESPONSE_JSON = JSON.stringify({ __notion_lite_pending: true });
7
11
  function compositeKey(idempotencyKey, commandName) {
8
12
  return `${idempotencyKey}\0${commandName}`;
9
13
  }
10
14
  export class IdempotencyStore {
11
15
  filePath;
16
+ lockPath;
12
17
  constructor(filePath = getIdempotencyStorePath()) {
13
18
  ensureConfigDir();
14
19
  this.filePath = filePath;
20
+ this.lockPath =
21
+ filePath === getIdempotencyStorePath() ? getIdempotencyLockPath() : `${filePath}.lock`;
15
22
  }
16
23
  close() {
17
24
  // no-op — nothing to tear down for a JSON file store
18
25
  }
19
26
  lookup(idempotencyKey, commandName, inputHash) {
20
- const data = this.loadData();
21
- const entry = data[compositeKey(idempotencyKey, commandName)];
22
- if (!entry) {
23
- return { kind: "miss" };
24
- }
25
- if (entry.inputHash !== inputHash) {
26
- return { kind: "conflict", storedHash: entry.inputHash };
27
- }
28
- if (entry.responseJson === PENDING_RESPONSE_JSON) {
29
- return { kind: "pending" };
30
- }
27
+ return this.withLock(() => {
28
+ const data = this.loadDataUnlocked();
29
+ const entry = data[compositeKey(idempotencyKey, commandName)];
30
+ if (!entry) {
31
+ return { kind: "miss" };
32
+ }
33
+ if (entry.inputHash !== inputHash) {
34
+ return { kind: "conflict", storedHash: entry.inputHash };
35
+ }
36
+ if (entry.responseJson === PENDING_RESPONSE_JSON) {
37
+ return { kind: "pending" };
38
+ }
39
+ try {
40
+ return { kind: "replay", response: JSON.parse(entry.responseJson) };
41
+ }
42
+ catch {
43
+ throw new CliError("internal_error", "Stored idempotency response is corrupt.");
44
+ }
45
+ });
46
+ }
47
+ reserve(idempotencyKey, commandName, inputHash) {
48
+ return this.withLock(() => {
49
+ const data = this.loadDataUnlocked();
50
+ const key = compositeKey(idempotencyKey, commandName);
51
+ if (!data[key]) {
52
+ data[key] = {
53
+ inputHash,
54
+ responseJson: PENDING_RESPONSE_JSON,
55
+ createdAt: Date.now(),
56
+ };
57
+ this.saveDataUnlocked(data);
58
+ return { kind: "execute" };
59
+ }
60
+ const entry = data[key];
61
+ if (entry.inputHash !== inputHash) {
62
+ return { kind: "conflict", storedHash: entry.inputHash };
63
+ }
64
+ if (entry.responseJson === PENDING_RESPONSE_JSON) {
65
+ return { kind: "pending" };
66
+ }
67
+ try {
68
+ return { kind: "replay", response: JSON.parse(entry.responseJson) };
69
+ }
70
+ catch {
71
+ throw new CliError("internal_error", "Stored idempotency response is corrupt.");
72
+ }
73
+ });
74
+ }
75
+ complete(idempotencyKey, commandName, inputHash, response) {
76
+ this.withLock(() => {
77
+ const data = this.loadDataUnlocked();
78
+ const key = compositeKey(idempotencyKey, commandName);
79
+ const entry = data[key];
80
+ if (!entry) {
81
+ data[key] = {
82
+ inputHash,
83
+ responseJson: JSON.stringify(response),
84
+ createdAt: Date.now(),
85
+ };
86
+ this.saveDataUnlocked(data);
87
+ return;
88
+ }
89
+ if (entry.inputHash !== inputHash) {
90
+ throw new CliError("internal_error", "Failed to finalize idempotency record for mutation replay.");
91
+ }
92
+ entry.responseJson = JSON.stringify(response);
93
+ entry.createdAt = Date.now();
94
+ this.saveDataUnlocked(data);
95
+ });
96
+ }
97
+ release(idempotencyKey, commandName, inputHash) {
98
+ this.withLock(() => {
99
+ const data = this.loadDataUnlocked();
100
+ const key = compositeKey(idempotencyKey, commandName);
101
+ const entry = data[key];
102
+ if (entry && entry.inputHash === inputHash && entry.responseJson === PENDING_RESPONSE_JSON) {
103
+ delete data[key];
104
+ this.saveDataUnlocked(data);
105
+ }
106
+ });
107
+ }
108
+ withLock(fn) {
109
+ const lockFd = this.acquireLock();
31
110
  try {
32
- return { kind: "replay", response: JSON.parse(entry.responseJson) };
111
+ return fn();
33
112
  }
34
- catch {
35
- throw new CliError("internal_error", "Stored idempotency response is corrupt.");
113
+ finally {
114
+ this.releaseLock(lockFd);
36
115
  }
37
116
  }
38
- reserve(idempotencyKey, commandName, inputHash) {
39
- const data = this.loadData();
40
- const key = compositeKey(idempotencyKey, commandName);
41
- if (!data[key]) {
42
- data[key] = {
43
- inputHash,
44
- responseJson: PENDING_RESPONSE_JSON,
45
- createdAt: Date.now(),
46
- };
47
- this.saveData(data);
48
- return { kind: "execute" };
117
+ acquireLock() {
118
+ const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
119
+ while (Date.now() < deadline) {
120
+ try {
121
+ const fd = openSync(this.lockPath, "wx");
122
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, "utf-8");
123
+ return fd;
124
+ }
125
+ catch (error) {
126
+ const code = error.code;
127
+ if (code !== "EEXIST") {
128
+ throw new CliError("internal_error", "Failed to acquire idempotency store lock.", {
129
+ details: error,
130
+ });
131
+ }
132
+ this.tryClearStaleLock();
133
+ this.sleepMs(LOCK_POLL_INTERVAL_MS + Math.floor(Math.random() * 20));
134
+ }
49
135
  }
50
- const entry = data[key];
51
- if (entry.inputHash !== inputHash) {
52
- return { kind: "conflict", storedHash: entry.inputHash };
136
+ throw new CliError("retryable_upstream", "Idempotency store is busy. Retry shortly.", {
137
+ retryable: true,
138
+ });
139
+ }
140
+ releaseLock(lockFd) {
141
+ try {
142
+ closeSync(lockFd);
53
143
  }
54
- if (entry.responseJson === PENDING_RESPONSE_JSON) {
55
- return { kind: "pending" };
144
+ catch {
145
+ // best effort
56
146
  }
57
147
  try {
58
- return { kind: "replay", response: JSON.parse(entry.responseJson) };
148
+ unlinkSync(this.lockPath);
59
149
  }
60
150
  catch {
61
- throw new CliError("internal_error", "Stored idempotency response is corrupt.");
151
+ // best effort
62
152
  }
63
153
  }
64
- complete(idempotencyKey, commandName, inputHash, response) {
65
- const data = this.loadData();
66
- const key = compositeKey(idempotencyKey, commandName);
67
- const entry = data[key];
68
- if (!entry || entry.inputHash !== inputHash) {
69
- throw new CliError("internal_error", "Failed to finalize idempotency record for mutation replay.");
154
+ tryClearStaleLock() {
155
+ try {
156
+ const lockStat = statSync(this.lockPath);
157
+ const ageMs = Date.now() - lockStat.mtimeMs;
158
+ if (ageMs > LOCK_STALE_MS) {
159
+ unlinkSync(this.lockPath);
160
+ }
70
161
  }
71
- entry.responseJson = JSON.stringify(response);
72
- entry.createdAt = Date.now();
73
- this.saveData(data);
74
- }
75
- release(idempotencyKey, commandName, inputHash) {
76
- const data = this.loadData();
77
- const key = compositeKey(idempotencyKey, commandName);
78
- const entry = data[key];
79
- if (entry && entry.inputHash === inputHash && entry.responseJson === PENDING_RESPONSE_JSON) {
80
- delete data[key];
81
- this.saveData(data);
162
+ catch {
163
+ // best effort
82
164
  }
83
165
  }
84
- loadData() {
166
+ sleepMs(ms) {
167
+ Atomics.wait(SLEEP_ARRAY, 0, 0, ms);
168
+ }
169
+ loadDataUnlocked() {
85
170
  let raw;
86
171
  try {
87
172
  raw = readFileSync(this.filePath, "utf-8");
@@ -94,6 +179,7 @@ export class IdempotencyStore {
94
179
  data = JSON.parse(raw);
95
180
  }
96
181
  catch {
182
+ this.saveDataUnlocked({});
97
183
  return {};
98
184
  }
99
185
  const now = Date.now();
@@ -105,12 +191,14 @@ export class IdempotencyStore {
105
191
  }
106
192
  }
107
193
  if (pruned) {
108
- this.saveData(data);
194
+ this.saveDataUnlocked(data);
109
195
  }
110
196
  return data;
111
197
  }
112
- saveData(data) {
113
- writeFileSync(this.filePath, JSON.stringify(data), "utf-8");
198
+ saveDataUnlocked(data) {
199
+ const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
200
+ writeFileSync(tempPath, JSON.stringify(data), "utf-8");
201
+ renameSync(tempPath, this.filePath);
114
202
  }
115
203
  }
116
204
  export function buildInternalIdempotencyKey(commandName, requestShape) {
@@ -265,6 +265,66 @@ function codeBlock(text, language) {
265
265
  },
266
266
  };
267
267
  }
268
+ function imageBlock(url, caption) {
269
+ return {
270
+ object: "block",
271
+ type: "image",
272
+ image: {
273
+ type: "external",
274
+ external: { url },
275
+ caption: toRichText(caption),
276
+ },
277
+ };
278
+ }
279
+ function parseMarkdownTable(lines, startIndex) {
280
+ const tableLines = [];
281
+ let i = startIndex;
282
+ while (i < lines.length && lines[i].trim().includes("|")) {
283
+ tableLines.push(lines[i].trim());
284
+ i++;
285
+ }
286
+ const linesConsumed = tableLines.length;
287
+ const parseCells = (line) => {
288
+ let stripped = line;
289
+ if (stripped.startsWith("|"))
290
+ stripped = stripped.slice(1);
291
+ if (stripped.endsWith("|"))
292
+ stripped = stripped.slice(0, -1);
293
+ return stripped.split("|").map((c) => c.trim());
294
+ };
295
+ const isSeparator = (line) => {
296
+ const cells = parseCells(line);
297
+ return cells.every((c) => /^[-:]+$/.test(c));
298
+ };
299
+ const hasColumnHeader = tableLines.length >= 2 && isSeparator(tableLines[1]);
300
+ const dataLines = tableLines.filter((_, idx) => !(hasColumnHeader && idx === 1));
301
+ const tableWidth = dataLines.length > 0 ? parseCells(dataLines[0]).length : 0;
302
+ const children = dataLines.map((line) => {
303
+ const cells = parseCells(line);
304
+ while (cells.length < tableWidth)
305
+ cells.push("");
306
+ return {
307
+ object: "block",
308
+ type: "table_row",
309
+ table_row: {
310
+ cells: cells.slice(0, tableWidth).map((c) => toRichText(c)),
311
+ },
312
+ };
313
+ });
314
+ return {
315
+ block: {
316
+ object: "block",
317
+ type: "table",
318
+ table: {
319
+ table_width: tableWidth,
320
+ has_column_header: hasColumnHeader,
321
+ has_row_header: false,
322
+ children,
323
+ },
324
+ },
325
+ linesConsumed,
326
+ };
327
+ }
268
328
  function isDivider(trimmed) {
269
329
  return trimmed === "---" || trimmed === "***" || trimmed === "___";
270
330
  }
@@ -312,6 +372,20 @@ export function markdownToBlocks(markdown) {
312
372
  index += 1;
313
373
  continue;
314
374
  }
375
+ const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
376
+ if (imageMatch) {
377
+ flushParagraph();
378
+ blocks.push(imageBlock(imageMatch[2], imageMatch[1]));
379
+ index += 1;
380
+ continue;
381
+ }
382
+ if (trimmed.startsWith("|")) {
383
+ flushParagraph();
384
+ const result = parseMarkdownTable(lines, index);
385
+ blocks.push(result.block);
386
+ index += result.linesConsumed;
387
+ continue;
388
+ }
315
389
  const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)$/);
316
390
  if (headingMatch) {
317
391
  flushParagraph();
@@ -140,3 +140,7 @@ export declare function replaceBlockRange(notion: NotionClientAdapter, args: {
140
140
  dryRun: boolean;
141
141
  maxBlocks: number;
142
142
  }): Promise<Record<string, unknown>>;
143
+ export declare function deleteBlocks(notion: NotionClientAdapter, args: {
144
+ blockIds: string[];
145
+ }): Promise<Record<string, unknown>>;
146
+ export declare function areBlocksDeleted(notion: NotionClientAdapter, blockIds: string[]): Promise<boolean>;
@@ -107,6 +107,94 @@ function readRelationProperty(page, propertyName) {
107
107
  .map((item) => item.id)
108
108
  .filter((value) => typeof value === "string" && value.length > 0);
109
109
  }
110
+ function collectFilterPropertyRefs(value, refs) {
111
+ if (!value || typeof value !== "object") {
112
+ return;
113
+ }
114
+ if (Array.isArray(value)) {
115
+ for (const item of value) {
116
+ collectFilterPropertyRefs(item, refs);
117
+ }
118
+ return;
119
+ }
120
+ const record = value;
121
+ if (typeof record.property === "string" && record.property.length > 0) {
122
+ refs.add(record.property);
123
+ }
124
+ for (const child of Object.values(record)) {
125
+ collectFilterPropertyRefs(child, refs);
126
+ }
127
+ }
128
+ function levenshteinDistance(a, b) {
129
+ const left = a.toLowerCase();
130
+ const right = b.toLowerCase();
131
+ if (left === right) {
132
+ return 0;
133
+ }
134
+ const matrix = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
135
+ for (let i = 0; i <= left.length; i += 1) {
136
+ matrix[i][0] = i;
137
+ }
138
+ for (let j = 0; j <= right.length; j += 1) {
139
+ matrix[0][j] = j;
140
+ }
141
+ for (let i = 1; i <= left.length; i += 1) {
142
+ for (let j = 1; j <= right.length; j += 1) {
143
+ const cost = left[i - 1] === right[j - 1] ? 0 : 1;
144
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
145
+ }
146
+ }
147
+ return matrix[left.length][right.length];
148
+ }
149
+ function suggestPropertyNames(raw, candidates) {
150
+ const target = raw.toLowerCase();
151
+ return [...candidates]
152
+ .sort((a, b) => {
153
+ const aLower = a.toLowerCase();
154
+ const bLower = b.toLowerCase();
155
+ const aStarts = aLower.startsWith(target) ? 0 : 1;
156
+ const bStarts = bLower.startsWith(target) ? 0 : 1;
157
+ if (aStarts !== bStarts) {
158
+ return aStarts - bStarts;
159
+ }
160
+ const aContains = aLower.includes(target) ? 0 : 1;
161
+ const bContains = bLower.includes(target) ? 0 : 1;
162
+ if (aContains !== bContains) {
163
+ return aContains - bContains;
164
+ }
165
+ const distanceDiff = levenshteinDistance(raw, a) - levenshteinDistance(raw, b);
166
+ if (distanceDiff !== 0) {
167
+ return distanceDiff;
168
+ }
169
+ return a.localeCompare(b);
170
+ })
171
+ .slice(0, 3);
172
+ }
173
+ async function validateDataSourceFilterProperties(ctx, dataSourceId, filter) {
174
+ if (!filter) {
175
+ return;
176
+ }
177
+ const refs = new Set();
178
+ collectFilterPropertyRefs(filter, refs);
179
+ if (refs.size === 0) {
180
+ return;
181
+ }
182
+ const schema = await hydrateDataSourceSchema(ctx, dataSourceId);
183
+ const validNames = Object.keys(schema);
184
+ const validIds = new Set(Object.values(schema)
185
+ .map((entry) => entry.id)
186
+ .filter((value) => typeof value === "string" && value.length > 0));
187
+ for (const property of refs) {
188
+ if (schema[property] || validIds.has(property)) {
189
+ continue;
190
+ }
191
+ const suggestions = suggestPropertyNames(property, validNames);
192
+ const suggestionText = suggestions.length > 0
193
+ ? ` Did you mean ${suggestions.map((name) => `"${name}"`).join(", ")}?`
194
+ : "";
195
+ throw new CliError("invalid_input", `Unknown filter property "${property}" for this data source.${suggestionText}`);
196
+ }
197
+ }
110
198
  async function withBestEffortPageMutation(ctx, pageId, apply) {
111
199
  const attempts = 2;
112
200
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
@@ -288,6 +376,7 @@ export async function getDataSourceSchema(notion, dataSourceId) {
288
376
  };
289
377
  }
290
378
  export async function queryDataSourcePages(ctx, input) {
379
+ await validateDataSourceFilterProperties(ctx, input.dataSourceId, input.filter);
291
380
  const payload = {
292
381
  data_source_id: input.dataSourceId,
293
382
  page_size: Math.min(Math.max(1, input.limit), 100),
@@ -455,6 +544,54 @@ function extractBlockText(block) {
455
544
  })
456
545
  .join("");
457
546
  }
547
+ function richTextToMarkdown(richTextArray) {
548
+ return richTextArray
549
+ .map((item) => {
550
+ if (!item || typeof item !== "object")
551
+ return "";
552
+ const plain = item.plain_text;
553
+ if (typeof plain !== "string")
554
+ return "";
555
+ const annotations = item.annotations;
556
+ const href = item.href ??
557
+ (item.text?.link?.url ?? null);
558
+ let result = plain;
559
+ if (annotations?.code) {
560
+ result = `\`${result}\``;
561
+ }
562
+ else {
563
+ if (annotations?.bold && annotations?.italic) {
564
+ result = `***${result}***`;
565
+ }
566
+ else if (annotations?.bold) {
567
+ result = `**${result}**`;
568
+ }
569
+ else if (annotations?.italic) {
570
+ result = `*${result}*`;
571
+ }
572
+ if (annotations?.strikethrough) {
573
+ result = `~~${result}~~`;
574
+ }
575
+ }
576
+ if (typeof href === "string") {
577
+ result = `[${result}](${href})`;
578
+ }
579
+ return result;
580
+ })
581
+ .join("");
582
+ }
583
+ function extractBlockMarkdown(block) {
584
+ const type = block.type;
585
+ if (typeof type !== "string")
586
+ return null;
587
+ const typedData = block[type];
588
+ if (!typedData || typeof typedData !== "object")
589
+ return null;
590
+ const richText = typedData.rich_text;
591
+ if (!Array.isArray(richText))
592
+ return null;
593
+ return richTextToMarkdown(richText);
594
+ }
458
595
  function toCompactBlock(block) {
459
596
  return {
460
597
  id: block.id ?? null,
@@ -508,7 +645,7 @@ function renderQuoteMarkdown(text, indent) {
508
645
  }
509
646
  function renderBlockToMarkdown(block, depth) {
510
647
  const type = typeof block.type === "string" ? block.type : "unsupported";
511
- const text = extractBlockText(block) ?? "";
648
+ const text = extractBlockMarkdown(block) ?? "";
512
649
  const indent = " ".repeat(depth);
513
650
  const children = collectRenderableChildren(block);
514
651
  const childMarkdown = children.length > 0 ? renderBlocksToMarkdown(children, depth + 1) : "";
@@ -557,6 +694,41 @@ function renderBlockToMarkdown(block, depth) {
557
694
  }
558
695
  case "toggle":
559
696
  return withChildren(`${indent}- ${text}`, true);
697
+ case "image": {
698
+ const img = block.image;
699
+ let imgUrl = "";
700
+ if (img) {
701
+ if (img.type === "external") {
702
+ const ext = img.external;
703
+ imgUrl = ext?.url ?? "";
704
+ }
705
+ else {
706
+ const file = img.file;
707
+ imgUrl = file?.url ?? "";
708
+ }
709
+ }
710
+ const caption = img && Array.isArray(img.caption) ? richTextToMarkdown(img.caption) : "";
711
+ return withChildren(`${indent}![${caption}](${imgUrl})`);
712
+ }
713
+ case "table": {
714
+ const tbl = block.table;
715
+ const rows = children;
716
+ const rowLines = [];
717
+ for (let ri = 0; ri < rows.length; ri++) {
718
+ const row = rows[ri];
719
+ const tr = row.table_row;
720
+ if (!tr?.cells)
721
+ continue;
722
+ const cellTexts = tr.cells.map((cell) => richTextToMarkdown(cell).replace(/\|/g, "\\|"));
723
+ rowLines.push(`${indent}| ${cellTexts.join(" | ")} |`);
724
+ if (ri === 0 && tbl?.has_column_header) {
725
+ rowLines.push(`${indent}| ${cellTexts.map(() => "---").join(" | ")} |`);
726
+ }
727
+ }
728
+ return rowLines.join("\n");
729
+ }
730
+ case "table_row":
731
+ return "";
560
732
  default: {
561
733
  const fallback = text.length > 0 ? text : `[${type}]`;
562
734
  return withChildren(`${indent}${fallback}`);
@@ -996,3 +1168,32 @@ export async function replaceBlockRange(notion, args) {
996
1168
  deleted_count: blocksToDelete.length,
997
1169
  };
998
1170
  }
1171
+ export async function deleteBlocks(notion, args) {
1172
+ const deletedIds = [];
1173
+ for (const blockId of args.blockIds) {
1174
+ await notion.deleteBlock({ block_id: blockId });
1175
+ deletedIds.push(blockId);
1176
+ }
1177
+ return {
1178
+ deleted_count: deletedIds.length,
1179
+ deleted_ids: deletedIds,
1180
+ };
1181
+ }
1182
+ export async function areBlocksDeleted(notion, blockIds) {
1183
+ for (const blockId of blockIds) {
1184
+ try {
1185
+ const block = asRecord(await notion.retrieveBlock(blockId));
1186
+ if (block.archived !== true) {
1187
+ return false;
1188
+ }
1189
+ }
1190
+ catch (error) {
1191
+ const status = getStatus(error);
1192
+ if (status === 404) {
1193
+ continue;
1194
+ }
1195
+ throw error;
1196
+ }
1197
+ }
1198
+ return true;
1199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntion",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Token-efficient CLI for personal Notion workspaces",
5
5
  "type": "module",
6
6
  "license": "MIT",