ntion 0.3.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
@@ -148,9 +148,11 @@ ntion pages unrelate --from-id <page_id> --property Project --to-id <page_id>
148
148
  ```bash
149
149
  # Read as markdown (default)
150
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)
151
153
 
152
154
  # Append markdown content
153
- 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'
154
156
  ntion blocks append --id <page_or_block_id> --markdown-file ./notes.md
155
157
 
156
158
  # Surgical insertion
@@ -197,7 +199,7 @@ Compact, deterministic, easy to parse — by humans or machines.
197
199
 
198
200
  - **Generic** — works with any Notion workspace, no hardcoded schema assumptions
199
201
  - **Compact** — deterministic JSON envelopes, minimal bytes per response
200
- - **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
201
203
  - **Fast** — zero native dependencies, internal schema caching, batch operations
202
204
  - **Agent-friendly** — designed for AI agents that pay per token
203
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, deleteBlocks, 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")
@@ -614,6 +635,16 @@ addCommonMutationOptions(pagesCommand.command("archive").description("Archive a
614
635
  view,
615
636
  fields,
616
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
+ },
617
648
  });
618
649
  return {
619
650
  data: {
@@ -647,6 +678,16 @@ addCommonMutationOptions(pagesCommand.command("unarchive").description("Unarchiv
647
678
  view,
648
679
  fields,
649
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
+ },
650
691
  });
651
692
  return {
652
693
  data: {
@@ -687,6 +728,16 @@ addCommonMutationOptions(pagesCommand.command("relate").description("Add a relat
687
728
  view,
688
729
  fields,
689
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
+ },
690
741
  });
691
742
  return {
692
743
  data: {
@@ -727,6 +778,16 @@ addCommonMutationOptions(pagesCommand.command("unrelate").description("Remove a
727
778
  view,
728
779
  fields,
729
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
+ },
730
791
  });
731
792
  return {
732
793
  data: {
@@ -736,27 +797,32 @@ addCommonMutationOptions(pagesCommand.command("unrelate").description("Remove a
736
797
  });
737
798
  });
738
799
  const blocksCommand = program.command("blocks").description("Block operations");
739
- blocksCommand
800
+ const blocksGetCommand = blocksCommand
740
801
  .command("get")
741
802
  .description("Get blocks from a page or block")
742
803
  .requiredOption("--id <page_or_block_id>", "Notion page or block ID")
743
804
  .option("--max-blocks <n>", "Maximum block count")
744
805
  .option("--depth <n>", "Recursion depth", "1")
745
- .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())
746
808
  .option("--pretty", "pretty-print JSON output")
747
809
  .option("--timeout-ms <n>", "request timeout in milliseconds")
748
810
  .action(async (options) => {
749
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
+ }
750
815
  const { config, notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
751
816
  const maxBlocks = parsePositiveInt(options.maxBlocks, "max-blocks", config.defaults.max_blocks);
752
817
  const depth = parsePositiveInt(options.depth, "depth", 1);
753
- const format = resolveBlockReadFormat(options.format, "markdown");
754
- 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);
755
820
  return {
756
821
  data: blocks,
757
822
  };
758
823
  });
759
824
  });
825
+ blocksGetCommand.addHelpText("after", BLOCKS_GET_HELP_EPILOG);
760
826
  const blocksAppendCommand = blocksCommand
761
827
  .command("append")
762
828
  .description("Append blocks to a page or block")
@@ -769,12 +835,12 @@ const blocksAppendCommand = blocksCommand
769
835
  .option("--timeout-ms <n>", "request timeout in milliseconds")
770
836
  .action(async (options) => {
771
837
  await runAction(Boolean(options.pretty), async (requestId) => {
772
- const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
773
838
  const blocks = await resolveBlocksInput({
774
839
  blocksJson: options.blocksJson,
775
840
  markdown: options.markdown,
776
841
  markdownFile: options.markdownFile,
777
842
  });
843
+ const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
778
844
  const result = await executeMutationWithIdempotency({
779
845
  commandName: options.dryRun ? "blocks.append.dry_run" : "blocks.append",
780
846
  requestId,
@@ -810,13 +876,13 @@ const blocksInsertCommand = blocksCommand
810
876
  .option("--timeout-ms <n>", "request timeout in milliseconds")
811
877
  .action(async (options) => {
812
878
  await runAction(Boolean(options.pretty), async (requestId) => {
813
- const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
814
879
  const blocks = await resolveBlocksInput({
815
880
  blocksJson: options.blocksJson,
816
881
  markdown: options.markdown,
817
882
  markdownFile: options.markdownFile,
818
883
  });
819
884
  const position = resolveInsertPosition(options.position, options.afterId);
885
+ const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
820
886
  const result = await executeMutationWithIdempotency({
821
887
  commandName: options.dryRun ? "blocks.insert.dry_run" : "blocks.insert",
822
888
  requestId,
@@ -881,7 +947,6 @@ const blocksReplaceRangeCommand = blocksCommand
881
947
  .option("--timeout-ms <n>", "request timeout in milliseconds")
882
948
  .action(async (options) => {
883
949
  await runAction(Boolean(options.pretty), async (requestId) => {
884
- const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
885
950
  const blocks = await resolveBlocksInput({
886
951
  blocksJson: options.blocksJson,
887
952
  markdown: options.markdown,
@@ -890,6 +955,7 @@ const blocksReplaceRangeCommand = blocksCommand
890
955
  const startSelector = requireSelectorJson(options.startSelectorJson, "start-selector-json");
891
956
  const endSelector = requireSelectorJson(options.endSelectorJson, "end-selector-json");
892
957
  const maxBlocks = parsePositiveInt(options.scanMaxBlocks, "scan-max-blocks", 5000);
958
+ const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
893
959
  const result = await executeMutationWithIdempotency({
894
960
  commandName: options.dryRun ? "blocks.replace_range.dry_run" : "blocks.replace_range",
895
961
  requestId,
@@ -936,6 +1002,16 @@ blocksCommand
936
1002
  requestShape: { block_ids: options.blockIds },
937
1003
  targetIds: options.blockIds,
938
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
+ },
939
1015
  });
940
1016
  return {
941
1017
  data: result,
@@ -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) {
@@ -143,3 +143,4 @@ export declare function replaceBlockRange(notion: NotionClientAdapter, args: {
143
143
  export declare function deleteBlocks(notion: NotionClientAdapter, args: {
144
144
  blockIds: string[];
145
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),
@@ -1090,3 +1179,21 @@ export async function deleteBlocks(notion, args) {
1090
1179
  deleted_ids: deletedIds,
1091
1180
  };
1092
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.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Token-efficient CLI for personal Notion workspaces",
5
5
  "type": "module",
6
6
  "license": "MIT",