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 +4 -2
- package/dist/audit/log.d.ts +4 -0
- package/dist/cli.js +87 -11
- package/dist/commands/mutation.d.ts +2 -0
- package/dist/commands/mutation.js +78 -2
- package/dist/config/paths.d.ts +1 -0
- package/dist/config/paths.js +3 -0
- package/dist/idempotency/store.d.ts +8 -2
- package/dist/idempotency/store.js +143 -55
- package/dist/notion/repository.d.ts +1 -0
- package/dist/notion/repository.js +107 -0
- package/package.json +1 -1
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
|
|
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
|
|
package/dist/audit/log.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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("--
|
|
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
|
|
754
|
-
const blocks = await getBlocks(notion, options.id, maxBlocks, depth,
|
|
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,
|
|
@@ -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
|
-
|
|
72
|
-
|
|
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;
|
package/dist/config/paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/paths.js
CHANGED
|
@@ -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
|
|
32
|
-
private
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
111
|
+
return fn();
|
|
33
112
|
}
|
|
34
|
-
|
|
35
|
-
|
|
113
|
+
finally {
|
|
114
|
+
this.releaseLock(lockFd);
|
|
36
115
|
}
|
|
37
116
|
}
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
144
|
+
catch {
|
|
145
|
+
// best effort
|
|
56
146
|
}
|
|
57
147
|
try {
|
|
58
|
-
|
|
148
|
+
unlinkSync(this.lockPath);
|
|
59
149
|
}
|
|
60
150
|
catch {
|
|
61
|
-
|
|
151
|
+
// best effort
|
|
62
152
|
}
|
|
63
153
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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.
|
|
194
|
+
this.saveDataUnlocked(data);
|
|
109
195
|
}
|
|
110
196
|
return data;
|
|
111
197
|
}
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|